refactor(planning): consolidate plan observations in one module

Move time-off periods, work sequences, public-holiday weeks, balance
observations, the placement list and both tie keys into
plan-observations.ts. The code moves without behavior changes; all seven
benchmark scenarios produce byte-identical outcomes, explanations and final
states, and the frozen-engine check passes. Benchmark timings stay within
noise.

Refs #282

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-16 23:47:34 +02:00
1 parent 24c35542a8
commit 87d72064be
3 files changed
+384 -281

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
# Issue #279: incremental exact scoring
Step-by-step record for [issue #279](https://gitea.nessar.fr/nessar/vac-optimizer/issues/279).
Each step keeps planning outcomes, stable explanations and final states identical
to develop after PR #278 (`a871f8f`). The
[frozen-engine differential check](README.md#frozen-engine-differential-check)
guards every step.
All measurements use Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the development
laptop, with no other test, build or profiling workload running alongside the
protocol. Only same-session before/after pairs are comparable.
```sh
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
```
The per-scenario figures below are the means of the two measured calculations.
## Baseline profile
A CPU profile of the bundled core over all seven scenarios, with
`calculationDate: '2024-01-01'`, put 50.5 of 67.0 loaded-year seconds in
candidate evaluation (`evaluateRuleCandidate`). Of those, 41.3 seconds went to
active-objective evaluation: 15.6 seconds in time-off-period observations,
11.5 seconds in building observations and 7.6 seconds in sorting placement dates
for the placement tie key and the observation cache key. The asynchronous
adapter's 1 ms timer yields cost about 7% on typed obligations.
## #282: consolidate plan observations
Time-off periods, work sequences, public-holiday weeks, balance observations,
the placement list and both tie keys now live in
`src/app/services/plan-observations.ts`. The code moved without behavior
changes. All seven scenarios produce byte-identical outcomes, explanations and
final states compared with `a871f8f`.
| Measurement | Before | After |
| ------------------- | --------: | --------: |
| Vitest duration | 262.000 s | 262.030 s |
| Loaded year | 66.010 s | 66.497 s |
| Typed obligations | 4.887 s | 4.964 s |
| Two years | 2.914 s | 2.977 s |
| Three years | 10.203 s | 10.049 s |
| Representative year | 0.557 s | 0.569 s |
The differences are within run-to-run noise. The functional suite passes
666 tests across 34 files, including the frozen-engine check.
+14 -281
View File
@@ -2,8 +2,21 @@ import {
DeclaredNonWorkingDay,
parseDeclaredDays,
frenchPublicHolidaysForYear,
isWorkingPlanningDate,
} from './declared-non-working-days';
import {
addDays,
compareDate,
dateRange,
evaluateStateOptimization,
expiryTieKey,
isAtRisk,
optimizationBalanceObservations,
optimizationObservations,
periodData,
placementTieKey,
utcDate,
workSequenceDates,
} from './plan-observations';
import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date';
import {
compareObjectiveLists,
@@ -501,21 +514,6 @@ const occurrenceCache = new WeakMap<SearchContext, Map<string, string[]>>();
const calendarPatternCache = new WeakMap<SearchContext, Map<string, string[][]>>();
const preferencePatternCache = new WeakMap<SearchContext, Map<string, string[][][]>>();
const declaredWindowCache = new WeakMap<SearchContext, Map<string, DeclaredNonWorkingDay[]>>();
type CalendarOptimizationObservations = Partial<
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
>;
const calendarObservationCache = new WeakMap<
SearchContext,
Map<string, CalendarOptimizationObservations>
>();
interface CalendarPeriod {
start: string;
end: string;
duration: number;
nonWorkingDates: readonly string[];
declaredDates: readonly string[];
}
const calendarPeriodCache = new WeakMap<SearchContext, Map<string, CalendarPeriod>>();
interface SearchContext {
declaredDays: readonly DeclaredNonWorkingDay[];
@@ -587,7 +585,6 @@ class UserCancelled extends Error {}
// A true value asks the asynchronous driver to pause immediately at a phase boundary.
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
const DAY_MS = 86_400_000;
// ponytail: cap the number of feasible obligation branches previewed for a
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
// that this ceiling misses a better lexicographic assignment.
@@ -635,29 +632,10 @@ function recordValue(value: unknown): RuntimeRecord | undefined {
return typeof value === 'object' && value !== null ? (value as RuntimeRecord) : undefined;
}
function utcDate(key: string): Date {
return new Date(`${key}T00:00:00Z`);
}
function addDays(key: string, days: number): string {
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
}
function compareDate(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function compareText(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function dateRange(start: string, endExclusive: string): string[] {
const result: string[] = [];
for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1))
result.push(date);
return result;
}
function defaultHorizon(start: string): { start: string; end: string } {
return { start, end: addPlanningYears(start, 1) };
}
@@ -3318,20 +3296,6 @@ function preferenceStateKey(state: State): string {
]);
}
function placementTieKey(state: State): string {
return [...state.placements.keys()].sort().join('|');
}
function expiryTieKey(state: State): string {
return [...state.placements.values()]
.map(
(placement) =>
state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? '',
)
.sort(compareDate)
.join('|');
}
function activeObjectiveVector(
state: State,
context: SearchContext,
@@ -3418,237 +3382,6 @@ function compareObligationPreviews(
);
}
function isAtRisk(balance: InternalBalance, context: SearchContext): boolean {
return (
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
compareDate(balance.expiryDate, context.horizon.end) <= 0
);
}
function isNaturalNonWorkingDate(date: string, context: SearchContext): boolean {
return context.calendarIndex.has(date)
? !context.workingCalendar.has(date)
: !isWorkingPlanningDate(date);
}
function calendarDateAt(index: number, context: SearchContext): string {
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
}
function periodBounds(state: State, context: SearchContext) {
const dates = [...new Set([...state.placements.keys(), ...context.declaredDates])]
.filter((date) => date >= context.horizon.start && date < context.horizon.end)
.sort(compareDate);
if (!dates.length) return [];
const groups: string[][] = [];
let group = [dates[0]];
for (let i = 1; i < dates.length; i++) {
context.check();
const previousIndex = context.calendarIndex.get(dates[i - 1])!;
const currentIndex = context.calendarIndex.get(dates[i])!;
const workingInGap =
context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1];
if (workingInGap === 0) group.push(dates[i]);
else {
groups.push(group);
group = [dates[i]];
}
}
groups.push(group);
return groups.map((anchors) => {
context.check();
let startIndex = context.calendarIndex.get(anchors[0])!;
let endIndex = context.calendarIndex.get(anchors[anchors.length - 1])!;
while (
context.declaredDates.has(calendarDateAt(startIndex - 1, context)) ||
isNaturalNonWorkingDate(calendarDateAt(startIndex - 1, context), context)
) {
context.check();
startIndex--;
}
while (
context.declaredDates.has(calendarDateAt(endIndex + 1, context)) ||
isNaturalNonWorkingDate(calendarDateAt(endIndex + 1, context), context)
) {
context.check();
endIndex++;
}
return { anchors, startIndex, endIndex };
});
}
function calendarPeriod(
startIndex: number,
endIndex: number,
context: SearchContext,
): CalendarPeriod {
let periods = calendarPeriodCache.get(context);
if (!periods) {
periods = new Map();
calendarPeriodCache.set(context, periods);
}
const key = `${startIndex}:${endIndex}`;
let period = periods.get(key);
if (period) periods.delete(key);
else {
const start = calendarDateAt(startIndex, context);
const end = calendarDateAt(endIndex, context);
const dates =
startIndex >= 0 && endIndex < context.calendarDays.length
? context.calendarDays.slice(startIndex, endIndex + 1)
: dateRange(start, addDays(end, 1));
period = Object.freeze({
start,
end,
duration: dates.length,
nonWorkingDates: Object.freeze(
dates.filter((date) => isNaturalNonWorkingDate(date, context)),
),
declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))),
});
}
periods.set(key, period);
if (periods.size > 4096) periods.delete(periods.keys().next().value!);
return period;
}
function periodData(state: State, context: SearchContext): TimeOffPeriod[] {
return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => {
const period = calendarPeriod(startIndex, endIndex, context);
return {
start: period.start,
end: period.end,
duration: period.duration,
placementDates: anchors.filter((date) => state.placements.has(date)),
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
nonWorkingDates: [...period.nonWorkingDates],
nonWorkingDayGain: period.nonWorkingDates.length,
};
});
}
function optimizationBalanceObservations(state: State, context: SearchContext) {
return state.balances.map((balance) => ({
unplacedQuantity: balance.remaining,
atRisk: isAtRisk(balance, context),
}));
}
function optimizationObservations(
state: State,
context: SearchContext,
periods?: TimeOffPeriod[],
): OptimizationObservations {
// Calendar facts depend only on occupied dates within this immutable request.
// Balance quantities, provenance and supporting status stay outside this cache.
const key = placementTieKey(state);
let byDates = calendarObservationCache.get(context);
if (!byDates) {
byDates = new Map();
calendarObservationCache.set(context, byDates);
}
const calendar = byDates.get(key) ?? {};
if (byDates.has(key)) byDates.delete(key);
else if (byDates.size >= 4096) byDates.delete(byDates.keys().next().value!);
byDates.set(key, calendar);
let bounds: ReturnType<typeof periodBounds> | undefined;
return {
leaveBalances: optimizationBalanceObservations(state, context),
get publicHolidayWeeks() {
return (calendar.publicHolidayWeeks ??= Object.freeze(
context.holidayWeeks.map((week) =>
Object.freeze({
value: week.value,
complete: week.dates.every(
(date) => state.placements.has(date) || context.declaredDates.has(date),
),
}),
),
));
},
get timeOffPeriods() {
return (calendar.timeOffPeriods ??= Object.freeze(
periods
? periods.map((period) =>
Object.freeze({
nonWorkingDates: Object.freeze([...period.nonWorkingDates]),
}),
)
: (bounds ??= periodBounds(state, context)).map(({ startIndex, endIndex }) =>
Object.freeze({
nonWorkingDates: calendarPeriod(startIndex, endIndex, context)
.nonWorkingDates,
}),
),
));
},
get workSequences() {
return (calendar.workSequences ??= Object.freeze(
workSequenceLengths((bounds ??= periodBounds(state, context)), context).map(
(sequence) => Object.freeze(sequence),
),
));
},
leavePlacements: [...state.placements.values()],
};
}
function workSequenceLengths(
bounds: ReturnType<typeof periodBounds>,
context: SearchContext,
): OptimizationObservations['workSequences'] {
if (!context.workingDays.length) return [];
let nextIndex = context.calendarIndex.get(context.horizon.start)!;
const sequences = bounds.map(({ anchors }, index) => {
const previousIndex = nextIndex;
nextIndex = context.calendarIndex.get(anchors[anchors.length - 1])! + 1;
return {
length:
context.workingPrefix[context.calendarIndex.get(anchors[0])!] -
context.workingPrefix[previousIndex],
internal: index > 0,
};
});
sequences.push({
length:
context.workingPrefix[context.calendarIndex.get(context.horizon.end)!] -
context.workingPrefix[nextIndex],
internal: false,
});
return sequences;
}
function workSequenceDates(state: State, context: SearchContext) {
const sequences: string[][] = [];
let current: string[] = [];
let inTimeOffPeriod = false;
for (const date of context.workingDays) {
context.check();
if (state.placements.has(date) || context.declaredDates.has(date)) {
if (!inTimeOffPeriod) sequences.push(current);
current = [];
inTimeOffPeriod = true;
} else {
current.push(date);
inTimeOffPeriod = false;
}
}
if (context.workingDays.length) sequences.push(current);
return sequences.map((dates, index) => ({
dates,
internal: index > 0 && index < sequences.length - 1,
}));
}
function evaluateStateOptimization(
configuration: OptimizationConfiguration,
state: State,
context: SearchContext,
observations = optimizationObservations(state, context),
): number {
return evaluateOptimization(configuration, observations);
}
function qualityMetrics(
state: State,
context: SearchContext,
+323
View File
@@ -0,0 +1,323 @@
import { isWorkingPlanningDate } from './declared-non-working-days';
import type { TimeOffPeriod } from './leave-planner.core';
import {
evaluateOptimization,
type OptimizationConfiguration,
type OptimizationObservations,
} from './planning-objectives';
// Everything planning objectives observe of a plan: periods, work sequences, holiday weeks, balances, placements and tie keys.
export interface ObservedBalance {
id: string;
remaining: number;
expiryDate: string;
}
export interface ObservedPlacement {
date: string;
balanceId: string;
supporting: boolean;
}
export interface ObservedPlan<P extends ObservedPlacement = ObservedPlacement> {
balances: readonly ObservedBalance[];
placements: ReadonlyMap<string, P>;
}
export interface ObservationCalendar {
horizon: { start: string; end: string };
declaredDates: ReadonlySet<string>;
holidayWeeks: readonly { dates: readonly string[]; value: number }[];
calendarDays: readonly string[];
calendarIndex: ReadonlyMap<string, number>;
workingCalendar: ReadonlySet<string>;
workingPrefix: readonly number[];
workingDays: readonly string[];
check: () => void;
}
const DAY_MS = 86_400_000;
export function utcDate(key: string): Date {
return new Date(`${key}T00:00:00Z`);
}
export function addDays(key: string, days: number): string {
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
}
export function compareDate(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
export function dateRange(start: string, endExclusive: string): string[] {
const result: string[] = [];
for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1))
result.push(date);
return result;
}
type CalendarOptimizationObservations = Partial<
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
>;
const calendarObservationCache = new WeakMap<
ObservationCalendar,
Map<string, CalendarOptimizationObservations>
>();
interface CalendarPeriod {
start: string;
end: string;
duration: number;
nonWorkingDates: readonly string[];
declaredDates: readonly string[];
}
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<string, CalendarPeriod>>();
export function placementTieKey(state: ObservedPlan): string {
return [...state.placements.keys()].sort().join('|');
}
export function expiryTieKey(state: ObservedPlan): string {
return [...state.placements.values()]
.map(
(placement) =>
state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? '',
)
.sort(compareDate)
.join('|');
}
export function isAtRisk(balance: ObservedBalance, context: ObservationCalendar): boolean {
return (
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
compareDate(balance.expiryDate, context.horizon.end) <= 0
);
}
function isNaturalNonWorkingDate(date: string, context: ObservationCalendar): boolean {
return context.calendarIndex.has(date)
? !context.workingCalendar.has(date)
: !isWorkingPlanningDate(date);
}
function calendarDateAt(index: number, context: ObservationCalendar): string {
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
}
function periodBounds(state: ObservedPlan, context: ObservationCalendar) {
const dates = [...new Set([...state.placements.keys(), ...context.declaredDates])]
.filter((date) => date >= context.horizon.start && date < context.horizon.end)
.sort(compareDate);
if (!dates.length) return [];
const groups: string[][] = [];
let group = [dates[0]];
for (let i = 1; i < dates.length; i++) {
context.check();
const previousIndex = context.calendarIndex.get(dates[i - 1])!;
const currentIndex = context.calendarIndex.get(dates[i])!;
const workingInGap =
context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1];
if (workingInGap === 0) group.push(dates[i]);
else {
groups.push(group);
group = [dates[i]];
}
}
groups.push(group);
return groups.map((anchors) => {
context.check();
let startIndex = context.calendarIndex.get(anchors[0])!;
let endIndex = context.calendarIndex.get(anchors[anchors.length - 1])!;
while (
context.declaredDates.has(calendarDateAt(startIndex - 1, context)) ||
isNaturalNonWorkingDate(calendarDateAt(startIndex - 1, context), context)
) {
context.check();
startIndex--;
}
while (
context.declaredDates.has(calendarDateAt(endIndex + 1, context)) ||
isNaturalNonWorkingDate(calendarDateAt(endIndex + 1, context), context)
) {
context.check();
endIndex++;
}
return { anchors, startIndex, endIndex };
});
}
function calendarPeriod(
startIndex: number,
endIndex: number,
context: ObservationCalendar,
): CalendarPeriod {
let periods = calendarPeriodCache.get(context);
if (!periods) {
periods = new Map();
calendarPeriodCache.set(context, periods);
}
const key = `${startIndex}:${endIndex}`;
let period = periods.get(key);
if (period) periods.delete(key);
else {
const start = calendarDateAt(startIndex, context);
const end = calendarDateAt(endIndex, context);
const dates =
startIndex >= 0 && endIndex < context.calendarDays.length
? context.calendarDays.slice(startIndex, endIndex + 1)
: dateRange(start, addDays(end, 1));
period = Object.freeze({
start,
end,
duration: dates.length,
nonWorkingDates: Object.freeze(
dates.filter((date) => isNaturalNonWorkingDate(date, context)),
),
declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))),
});
}
periods.set(key, period);
if (periods.size > 4096) periods.delete(periods.keys().next().value!);
return period;
}
export function periodData(state: ObservedPlan, context: ObservationCalendar): TimeOffPeriod[] {
return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => {
const period = calendarPeriod(startIndex, endIndex, context);
return {
start: period.start,
end: period.end,
duration: period.duration,
placementDates: anchors.filter((date) => state.placements.has(date)),
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
nonWorkingDates: [...period.nonWorkingDates],
nonWorkingDayGain: period.nonWorkingDates.length,
};
});
}
export function optimizationBalanceObservations(state: ObservedPlan, context: ObservationCalendar) {
return state.balances.map((balance) => ({
unplacedQuantity: balance.remaining,
atRisk: isAtRisk(balance, context),
}));
}
export function optimizationObservations(
state: ObservedPlan,
context: ObservationCalendar,
periods?: TimeOffPeriod[],
): OptimizationObservations {
// Calendar facts depend only on occupied dates within this immutable request.
// Balance quantities, provenance and supporting status stay outside this cache.
const key = placementTieKey(state);
let byDates = calendarObservationCache.get(context);
if (!byDates) {
byDates = new Map();
calendarObservationCache.set(context, byDates);
}
const calendar = byDates.get(key) ?? {};
if (byDates.has(key)) byDates.delete(key);
else if (byDates.size >= 4096) byDates.delete(byDates.keys().next().value!);
byDates.set(key, calendar);
let bounds: ReturnType<typeof periodBounds> | undefined;
return {
leaveBalances: optimizationBalanceObservations(state, context),
get publicHolidayWeeks() {
return (calendar.publicHolidayWeeks ??= Object.freeze(
context.holidayWeeks.map((week) =>
Object.freeze({
value: week.value,
complete: week.dates.every(
(date) => state.placements.has(date) || context.declaredDates.has(date),
),
}),
),
));
},
get timeOffPeriods() {
return (calendar.timeOffPeriods ??= Object.freeze(
periods
? periods.map((period) =>
Object.freeze({
nonWorkingDates: Object.freeze([...period.nonWorkingDates]),
}),
)
: (bounds ??= periodBounds(state, context)).map(({ startIndex, endIndex }) =>
Object.freeze({
nonWorkingDates: calendarPeriod(startIndex, endIndex, context)
.nonWorkingDates,
}),
),
));
},
get workSequences() {
return (calendar.workSequences ??= Object.freeze(
workSequenceLengths((bounds ??= periodBounds(state, context)), context).map(
(sequence) => Object.freeze(sequence),
),
));
},
leavePlacements: [...state.placements.values()],
};
}
function workSequenceLengths(
bounds: ReturnType<typeof periodBounds>,
context: ObservationCalendar,
): OptimizationObservations['workSequences'] {
if (!context.workingDays.length) return [];
let nextIndex = context.calendarIndex.get(context.horizon.start)!;
const sequences = bounds.map(({ anchors }, index) => {
const previousIndex = nextIndex;
nextIndex = context.calendarIndex.get(anchors[anchors.length - 1])! + 1;
return {
length:
context.workingPrefix[context.calendarIndex.get(anchors[0])!] -
context.workingPrefix[previousIndex],
internal: index > 0,
};
});
sequences.push({
length:
context.workingPrefix[context.calendarIndex.get(context.horizon.end)!] -
context.workingPrefix[nextIndex],
internal: false,
});
return sequences;
}
export function workSequenceDates(state: ObservedPlan, context: ObservationCalendar) {
const sequences: string[][] = [];
let current: string[] = [];
let inTimeOffPeriod = false;
for (const date of context.workingDays) {
context.check();
if (state.placements.has(date) || context.declaredDates.has(date)) {
if (!inTimeOffPeriod) sequences.push(current);
current = [];
inTimeOffPeriod = true;
} else {
current.push(date);
inTimeOffPeriod = false;
}
}
if (context.workingDays.length) sequences.push(current);
return sequences.map((dates, index) => ({
dates,
internal: index > 0 && index < sequences.length - 1,
}));
}
export function evaluateStateOptimization(
configuration: OptimizationConfiguration,
state: ObservedPlan,
context: ObservationCalendar,
observations = optimizationObservations(state, context),
): number {
return evaluateOptimization(configuration, observations);
}