From 7890a03a74440bb8e186ae4fe48522912600973e Mon Sep 17 00:00:00 2001
From: Nessar
Date: Sat, 17 Oct 2026 00:14:21 +0200
Subject: [PATCH] perf(planning): update plan observations incrementally
Cache an immutable occupancy per placements map and derive a candidate's
time-off period groups, holiday-week completeness and placement tie key
from its parent when one date is added or removed. The from-scratch
computation stays as the test oracle and an opt-in shadow mode. A
fast-check property test compares both after every operation.
The loaded year drops from 66.5 s to 48.5 s and the protocol from 262.0 s
to 193.7 s. Outcomes, explanations and final states stay identical.
Refs #283
Co-Authored-By: Claude Opus 5.5
---
bun.lock | 7 +-
docs/benchmark/issue-279.md | 39 ++
package.json | 1 +
src/app/services/leave-planner.core.ts | 49 +-
src/app/services/plan-observations.spec.ts | 198 ++++++++
src/app/services/plan-observations.ts | 512 ++++++++++++++++++---
6 files changed, 717 insertions(+), 89 deletions(-)
create mode 100644 src/app/services/plan-observations.spec.ts
diff --git a/bun.lock b/bun.lock
index b752c0b..1c10706 100644
--- a/bun.lock
+++ b/bun.lock
@@ -42,6 +42,7 @@
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
+ "fast-check": "4.10.2",
"jest": "^30.4.2",
"jest-preset-angular": "^17.0.0",
"jsdom": "^30.0.0",
@@ -1405,6 +1406,8 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
+ "fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="],
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
@@ -1969,7 +1972,7 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
- "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
+ "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
@@ -2949,6 +2952,8 @@
"jest-circus/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="],
+ "jest-circus/pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
+
"jest-cli/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="],
"jest-cli/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
diff --git a/docs/benchmark/issue-279.md b/docs/benchmark/issue-279.md
index e932a71..03e8928 100644
--- a/docs/benchmark/issue-279.md
+++ b/docs/benchmark/issue-279.md
@@ -45,3 +45,42 @@ final states compared with `a871f8f`.
The differences are within run-to-run noise. The functional suite passes
666 tests across 34 files, including the frozen-engine check.
+
+## #283: incremental plan observations
+
+Each placements map now carries an immutable occupancy: its sorted placement
+dates, time-off period groups, public-holiday-week completeness and lazily
+computed calendar observations. A candidate derived from its parent by one added
+or removed date, or by a copy with the same dates, updates only the affected
+groups and holiday weeks. Unchanged groups keep their cached periods and
+observation objects. Maps built from snapshots, maps observed against another
+calendar and dates that are not in-horizon working days fall back to the
+from-scratch computation.
+
+The from-scratch computation remains the oracle. A fast-check property test
+applies random add, remove and retype sequences across year boundaries, horizon
+edges, declared days, weekends and public holidays, and compares every step with
+it. An opt-in shadow mode (`VAC_OBSERVATION_SHADOW=1` or
+`setObservationShadowMode(true)`) compares both on every observation and throws
+on any mismatch. It passed across the frozen-engine check and all seven
+benchmark scenarios, whose outcomes, explanations and final states stay
+byte-identical.
+
+| Measurement | Before | After |
+| ------------------- | --------: | --------: |
+| Vitest duration | 262.030 s | 193.680 s |
+| Loaded year | 66.497 s | 48.489 s |
+| Typed obligations | 4.964 s | 4.947 s |
+| Two years | 2.977 s | 1.906 s |
+| Three years | 10.049 s | 6.152 s |
+| Representative year | 0.569 s | 0.441 s |
+
+A fresh loaded-year CPU profile of the bundled core took 46.8 seconds. Of the
+1.68 million observed placement maps, one was built from scratch; the rest were
+derived. Observations now take about 4 seconds. Candidate evaluation still
+dominates: 24.3 seconds in `evaluateRuleCandidate`, including 15.2 seconds of
+active objectives and 6.9 seconds of candidate finalization, plus 6.2 seconds
+assigning dates and about 5 seconds each of garbage collection and timer idle
+time.
+
+The functional suite passes 669 tests across 35 files.
diff --git a/package.json b/package.json
index 5f1ff84..83dd2c1 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
+ "fast-check": "4.10.2",
"jest": "^30.4.2",
"jest-preset-angular": "^17.0.0",
"jsdom": "^30.0.0",
diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts
index 3de2dca..4359f13 100644
--- a/src/app/services/leave-planner.core.ts
+++ b/src/app/services/leave-planner.core.ts
@@ -13,6 +13,9 @@ import {
optimizationBalanceObservations,
optimizationObservations,
periodData,
+ derivePlacements,
+ notePlacementAdded,
+ notePlacementRemoved,
placementTieKey,
utcDate,
workSequenceDates,
@@ -698,19 +701,21 @@ function reason(code: string, message: string, ruleIds?: string[]): PlanningReas
}
function cloneState(state: State, placements?: State['placements']): State {
+ const clonedPlacements =
+ placements ??
+ new Map(
+ [...state.placements.entries()].map(([date, placement]) => [
+ date,
+ {
+ ...placement,
+ ruleIds: new Set(placement.ruleIds),
+ },
+ ]),
+ );
+ derivePlacements(state.placements, clonedPlacements);
return {
balances: state.balances.map((balance) => ({ ...balance })),
- placements:
- placements ??
- new Map(
- [...state.placements.entries()].map(([date, placement]) => [
- date,
- {
- ...placement,
- ruleIds: new Set(placement.ruleIds),
- },
- ]),
- ),
+ placements: clonedPlacements,
earnedActivations: state.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
@@ -1460,6 +1465,7 @@ function assignDateVariants(
const result = cloneState(state);
const target = result.balances.find((item) => item.id === balance.id)!;
target.remaining--;
+ notePlacementAdded(state.placements, result.placements, date);
result.placements.set(date, {
date,
balanceId: target.id,
@@ -1498,6 +1504,7 @@ function assignDateToBalance(
const result = cloneState(state, new Map(state.placements));
const target = result.balances.find((item) => item.id === balanceId)!;
target.remaining--;
+ notePlacementAdded(state.placements, result.placements, date);
result.placements.set(date, {
date,
balanceId,
@@ -1838,6 +1845,7 @@ function releasePlacement(state: State, date: string): State {
})),
};
const placement = result.placements.get(date)!;
+ notePlacementRemoved(state.placements, result.placements, date);
result.placements.delete(date);
result.balances.find((balance) => balance.id === placement.balanceId)!.remaining++;
return result;
@@ -2811,7 +2819,7 @@ function* optimizeRulePlacements(
moved &&
dateInvariantObjectives &&
compareObjectiveLists(
- tieBreakObjectives(moved),
+ tieBreakObjectives(moved, context),
beforeVector.objectives.slice(-2),
) >= 0
) {
@@ -3346,7 +3354,7 @@ function* activeObjectives(
}
}
-function tieBreakObjectives(state: State): PlanningObjective[] {
+function tieBreakObjectives(state: State, context: SearchContext): PlanningObjective[] {
return [
{
id: 'balance-expiry',
@@ -3357,7 +3365,7 @@ function tieBreakObjectives(state: State): PlanningObjective[] {
{
id: 'placement-date',
direction: 'minimize',
- value: placementTieKey(state),
+ value: placementTieKey(state, context),
label: 'Placement date tie-break',
},
];
@@ -3367,7 +3375,10 @@ function obligationPreviewObjectives(
preview: { state: State },
context: SearchContext,
): PlanningObjective[] {
- return [...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state)];
+ return [
+ ...activeObjectiveVector(preview.state, context),
+ ...tieBreakObjectives(preview.state, context),
+ ];
}
/** Compare complete previews so obligation DFS does not commit to its first feasible branch. */
@@ -3553,7 +3564,7 @@ function objectiveVector(
label: 'Unsatisfied obligations',
},
...active,
- ...tieBreakObjectives(state),
+ ...tieBreakObjectives(state, context),
],
};
}
@@ -3692,6 +3703,7 @@ function planExplanation(
compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0
: false;
const withoutPlacement = cloneState(state);
+ notePlacementRemoved(state.placements, withoutPlacement.placements, placement.date);
withoutPlacement.placements.delete(placement.date);
const released = withoutPlacement.balances.find(
(candidate) => candidate.id === placement.balanceId,
@@ -4975,7 +4987,10 @@ export function analyzeCounterfactualInternal(
const baseline = cloneState(finalState);
const removeDate = selected.date;
const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
- if (removeDate) baseline.placements.delete(removeDate);
+ if (removeDate) {
+ notePlacementRemoved(finalState.placements, baseline.placements, removeDate);
+ baseline.placements.delete(removeDate);
+ }
const released = baseline.balances.find((balance) => balance.id === selected.balanceId);
if (released && removed?.balanceId === selected.balanceId) released.remaining++;
const beforeState = baseline;
diff --git a/src/app/services/plan-observations.spec.ts b/src/app/services/plan-observations.spec.ts
new file mode 100644
index 0000000..dd746c8
--- /dev/null
+++ b/src/app/services/plan-observations.spec.ts
@@ -0,0 +1,198 @@
+import fc from 'fast-check';
+import { describe, expect, it } from 'vitest';
+import { frenchPublicHolidaysForYear } from './declared-non-working-days';
+import {
+ addDays,
+ compareDate,
+ derivePlacements,
+ notePlacementAdded,
+ notePlacementRemoved,
+ observationsFromScratch,
+ optimizationObservations,
+ periodData,
+ placementTieKey,
+ setObservationShadowMode,
+ utcDate,
+ type ObservationCalendar,
+ type ObservedPlacement,
+ type ObservedPlan,
+} from './plan-observations';
+
+function calendar(start: string, length: number, declared: readonly string[]): ObservationCalendar {
+ const end = addDays(start, length);
+ const first = addDays(start, -400);
+ const last = addDays(end, 400);
+ const holidays = new Set();
+ for (let year = Number(first.slice(0, 4)); year <= Number(last.slice(0, 4)); year++)
+ for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday);
+ const isWorking = (date: string) =>
+ ![0, 6].includes(utcDate(date).getUTCDay()) && !holidays.has(date);
+ const calendarDays: string[] = [];
+ const calendarIndex = new Map();
+ const workingCalendar = new Set();
+ const workingDays: string[] = [];
+ const workingPrefix = [0];
+ for (let date = first; compareDate(date, last) < 0; date = addDays(date, 1)) {
+ calendarIndex.set(date, calendarDays.length);
+ calendarDays.push(date);
+ const working = isWorking(date);
+ if (working) {
+ workingCalendar.add(date);
+ if (date >= start && date < end) workingDays.push(date);
+ }
+ workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working));
+ }
+ const weeks = new Map();
+ for (const holiday of holidays) {
+ const weekday = utcDate(holiday).getUTCDay();
+ if (holiday < start || holiday >= end || weekday < 1 || weekday > 5) continue;
+ const monday = addDays(holiday, 1 - weekday);
+ const dates = [0, 1, 2, 3, 4].map((i) => addDays(monday, i)).filter(isWorking);
+ weeks.set(monday, { dates, value: 1 + (weekday % 3) });
+ }
+ return {
+ horizon: { start, end },
+ declaredDates: new Set(declared),
+ holidayWeeks: [...weeks.values()],
+ calendarDays,
+ calendarIndex,
+ workingCalendar,
+ workingPrefix,
+ workingDays,
+ check: () => {},
+ };
+}
+
+type Op = { kind: 'add' | 'remove' | 'retype'; pick: number };
+const scenario = fc.record({
+ // Horizons around year boundaries, Easter Monday, 1 May and Ascension 2024.
+ start: fc.constantFrom('2023-12-18', '2024-03-28', '2024-04-24', '2024-05-06', '2024-12-23'),
+ length: fc.integer({ min: 1, max: 45 }),
+ declared: fc.array(fc.integer({ min: -3, max: 48 }), { maxLength: 8 }),
+ ops: fc.array(
+ fc.record({
+ kind: fc.constantFrom('add', 'add', 'remove', 'retype'),
+ pick: fc.nat(1000),
+ }),
+ { maxLength: 40 },
+ ),
+});
+
+function observe(plan: ObservedPlan, context: ObservationCalendar) {
+ const observations = optimizationObservations(plan, context);
+ return {
+ tieKey: placementTieKey(plan, context),
+ periods: periodData(plan, context),
+ publicHolidayWeeks: observations.publicHolidayWeeks,
+ timeOffPeriods: observations.timeOffPeriods,
+ workSequences: observations.workSequences,
+ };
+}
+
+function expectScratch(plan: ObservedPlan, context: ObservationCalendar) {
+ const scratch = observationsFromScratch(plan, context);
+ expect(observe(plan, context)).toEqual({
+ tieKey: scratch.tieKey,
+ periods: scratch.periods,
+ publicHolidayWeeks: scratch.publicHolidayWeeks,
+ timeOffPeriods: scratch.timeOffPeriods,
+ workSequences: scratch.workSequences,
+ });
+}
+
+const placement = (date: string, balanceId: string, supporting = false): ObservedPlacement => ({
+ date,
+ balanceId,
+ supporting,
+});
+
+describe('incremental plan observations', () => {
+ it('match the from-scratch computation after every command', () => {
+ fc.assert(
+ fc.property(scenario, ({ start, length, declared, ops }) => {
+ const context = calendar(
+ start,
+ length,
+ declared.map((offset) => addDays(start, offset)),
+ );
+ let plan: ObservedPlan = { balances: [], placements: new Map() };
+ expectScratch(plan, context);
+ ops.forEach(({ kind, pick }, step) => {
+ const parent = plan.placements as Map;
+ // Every fourth add may hit a weekend, holiday, declared date or a date up to three
+ // days outside the horizon, as snapshots can.
+ const free = (
+ pick % 4
+ ? context.workingDays.filter((date) => !context.declaredDates.has(date))
+ : context.calendarDays.filter(
+ (date) =>
+ date >= addDays(context.horizon.start, -3) &&
+ date < addDays(context.horizon.end, 3),
+ )
+ ).filter((date) => !parent.has(date));
+ const used = [...parent.keys()];
+ const child = new Map(parent);
+ if (kind === 'add' && free.length) {
+ const date = free[pick % free.length];
+ notePlacementAdded(parent, child, date);
+ child.set(date, placement(date, `b${step}`));
+ } else if (kind === 'remove' && used.length) {
+ const date = used[pick % used.length];
+ notePlacementRemoved(parent, child, date);
+ child.delete(date);
+ } else if (kind === 'retype' && used.length) {
+ const date = used[pick % used.length];
+ derivePlacements(parent, child);
+ child.set(date, placement(date, `r${step}`, true));
+ } else return;
+ plan = { balances: [], placements: child };
+ expectScratch(plan, context);
+ });
+ }),
+ { numRuns: 300 },
+ );
+ });
+
+ it('resolves a chain of unobserved derivations', () => {
+ const context = calendar('2024-03-28', 20, ['2024-04-04']);
+ const root = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
+ expectScratch({ balances: [], placements: root }, context);
+ let parent = root;
+ for (const date of ['2024-04-03', '2024-04-05', '2024-04-08']) {
+ const child = new Map(parent);
+ notePlacementAdded(parent, child, date);
+ child.set(date, placement(date, 'b'));
+ parent = child;
+ }
+ const child = new Map(parent);
+ notePlacementRemoved(parent, child, '2024-04-03');
+ child.delete('2024-04-03');
+ expectScratch({ balances: [], placements: child }, context);
+ });
+
+ it('derives a child from an observed parent', () => {
+ const context = calendar('2024-03-28', 20, []);
+ const parent = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
+ expectScratch({ balances: [], placements: parent }, context);
+ const child = new Map(parent);
+ notePlacementAdded(parent, child, '2024-04-03');
+ child.set('2024-04-03', placement('2024-04-03', 'b'));
+ expectScratch({ balances: [], placements: child }, context);
+ });
+});
+
+describe('observation shadow mode', () => {
+ it('throws when a placements Map is mutated in place after being observed', () => {
+ const context = calendar('2024-03-28', 20, []);
+ const placements = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
+ const plan: ObservedPlan = { balances: [], placements };
+ setObservationShadowMode(true);
+ try {
+ optimizationObservations(plan, context);
+ placements.set('2024-04-10', placement('2024-04-10', 'b'));
+ expect(() => optimizationObservations(plan, context)).toThrow(/shadow mismatch/);
+ } finally {
+ setObservationShadowMode(false);
+ }
+ });
+});
diff --git a/src/app/services/plan-observations.ts b/src/app/services/plan-observations.ts
index 6992750..6c54ccd 100644
--- a/src/app/services/plan-observations.ts
+++ b/src/app/services/plan-observations.ts
@@ -62,11 +62,6 @@ type CalendarOptimizationObservations = Partial<
Pick
>;
-const calendarObservationCache = new WeakMap<
- ObservationCalendar,
- Map
->();
-
interface CalendarPeriod {
start: string;
end: string;
@@ -75,10 +70,288 @@ interface CalendarPeriod {
declaredDates: readonly string[];
}
-const calendarPeriodCache = new WeakMap>();
+const calendarPeriodCache = new WeakMap>();
-export function placementTieKey(state: ObservedPlan): string {
- return [...state.placements.keys()].sort().join('|');
+interface Group {
+ anchors: readonly string[];
+ startIndex: number;
+ endIndex: number;
+ period?: CalendarPeriod;
+ timeOff?: { readonly nonWorkingDates: readonly string[] };
+}
+
+// Immutable view of one placements Map's key set, valid for one calendar.
+interface Occupancy {
+ calendar: ObservationCalendar;
+ dates: readonly string[];
+ groups: readonly Group[];
+ tieKey?: string;
+ observed: CalendarOptimizationObservations;
+}
+
+interface Derivation {
+ parent: ReadonlyMap;
+ op: 'same' | 'add' | 'remove';
+ date?: string;
+}
+
+const occupancies = new WeakMap, Occupancy>();
+const derivations = new WeakMap, Derivation>();
+
+let shadowMode =
+ (globalThis as { process?: { env?: Record } }).process?.env?.[
+ 'VAC_OBSERVATION_SHADOW'
+ ] === '1';
+
+export function setObservationShadowMode(enabled: boolean): void {
+ shadowMode = enabled;
+}
+
+function note(
+ parent: ReadonlyMap,
+ child: ReadonlyMap,
+ op: Derivation['op'],
+ date?: string,
+): void {
+ // A stale occupancy of the child would describe its key set before this change.
+ occupancies.delete(child);
+ if (parent === child) return;
+ derivations.set(child, { parent, op, date });
+}
+
+/** `child` is a copy of `parent` with the same key set (placement objects may differ). */
+export function derivePlacements(
+ parent: ReadonlyMap,
+ child: ReadonlyMap,
+): void {
+ note(parent, child, 'same');
+}
+
+/** Call before `child.set(date, ...)` where `child` is a fresh copy of `parent` lacking `date`. */
+export function notePlacementAdded(
+ parent: ReadonlyMap,
+ child: ReadonlyMap,
+ date: string,
+): void {
+ note(parent, child, 'add', date);
+}
+
+/** Call before `child.delete(date)` where `child` is a fresh copy of `parent` holding `date`. */
+export function notePlacementRemoved(
+ parent: ReadonlyMap,
+ child: ReadonlyMap,
+ date: string,
+): void {
+ note(parent, child, 'remove', date);
+}
+
+function insertionPoint(sorted: readonly string[], date: string): number {
+ let low = 0;
+ let high = sorted.length;
+ while (low < high) {
+ const mid = (low + high) >> 1;
+ if (sorted[mid] < date) low = mid + 1;
+ else high = mid;
+ }
+ return low;
+}
+
+function occupancyFromScratch(
+ placements: ReadonlyMap,
+ context: ObservationCalendar,
+): Occupancy {
+ const dates = [...placements.keys()].sort();
+ return { calendar: context, dates, groups: groupsFromScratch(dates, context), observed: {} };
+}
+
+function deriveOccupancy(
+ parent: Occupancy,
+ { op, date }: Derivation,
+ context: ObservationCalendar,
+): Occupancy | undefined {
+ if (op === 'same') return parent;
+ const at = insertionPoint(parent.dates, date!);
+ const present = parent.dates[at] === date;
+ if (present === (op === 'add')) return undefined;
+ const dates = parent.dates.slice();
+ if (op === 'add') dates.splice(at, 0, date!);
+ else dates.splice(at, 1);
+ const touchesGroups =
+ date! >= context.horizon.start &&
+ date! < context.horizon.end &&
+ !context.declaredDates.has(date!);
+ // Group merging and splitting assume a working day; snapshots may hold any date.
+ if (touchesGroups && !context.workingCalendar.has(date!)) return undefined;
+ const groups = !touchesGroups
+ ? parent.groups
+ : op === 'add'
+ ? groupsWithDate(parent.groups, date!, context)
+ : groupsWithoutDate(parent.groups, date!, context);
+ if (!groups) return undefined;
+ const observed: CalendarOptimizationObservations = {};
+ if (parent.observed.publicHolidayWeeks)
+ observed.publicHolidayWeeks = derivedHolidayWeeks(
+ parent.observed.publicHolidayWeeks,
+ dates,
+ date!,
+ context,
+ );
+ return { calendar: context, dates, groups, observed };
+}
+
+const weeksByDate = new WeakMap>();
+
+function derivedHolidayWeeks(
+ weeks: NonNullable,
+ dates: readonly string[],
+ date: string,
+ context: ObservationCalendar,
+) {
+ let index = weeksByDate.get(context);
+ if (!index) {
+ index = new Map();
+ context.holidayWeeks.forEach((week, i) =>
+ week.dates.forEach((d) => {
+ const list = index!.get(d);
+ if (list) list.push(i);
+ else index!.set(d, [i]);
+ }),
+ );
+ weeksByDate.set(context, index);
+ }
+ const affected = index.get(date);
+ if (!affected) return weeks;
+ const next = weeks.slice();
+ for (const i of affected) {
+ const week = context.holidayWeeks[i];
+ const complete = week.dates.every((d) => {
+ const at = insertionPoint(dates, d);
+ return dates[at] === d || context.declaredDates.has(d);
+ });
+ if (complete !== weeks[i].complete)
+ next[i] = Object.freeze({ value: week.value, complete });
+ }
+ return Object.freeze(next);
+}
+
+function groupsWithDate(
+ groups: readonly Group[],
+ date: string,
+ context: ObservationCalendar,
+): readonly Group[] {
+ let low = 0;
+ let high = groups.length;
+ while (low < high) {
+ const mid = (low + high) >> 1;
+ if (groups[mid].anchors[0] < date) low = mid + 1;
+ else high = mid;
+ }
+ const previous = low > 0 ? groups[low - 1] : undefined;
+ const next = low < groups.length ? groups[low] : undefined;
+ const index = context.calendarIndex.get(date)!;
+ const joinsPrevious =
+ !!previous &&
+ context.workingPrefix[index] -
+ context.workingPrefix[
+ context.calendarIndex.get(previous.anchors[previous.anchors.length - 1])! + 1
+ ] ===
+ 0;
+ const joinsNext =
+ !!next &&
+ context.workingPrefix[context.calendarIndex.get(next.anchors[0])!] -
+ context.workingPrefix[index + 1] ===
+ 0;
+ const merged: Group = {
+ anchors: [
+ ...(joinsPrevious ? previous!.anchors : []),
+ date,
+ ...(joinsNext ? next!.anchors : []),
+ ],
+ startIndex: joinsPrevious ? previous!.startIndex : extendStart(index, context),
+ endIndex: joinsNext ? next!.endIndex : extendEnd(index, context),
+ };
+ const from = joinsPrevious ? low - 1 : low;
+ return [...groups.slice(0, from), merged, ...groups.slice(joinsNext ? low + 1 : low)];
+}
+
+function groupsWithoutDate(
+ groups: readonly Group[],
+ date: string,
+ context: ObservationCalendar,
+): readonly Group[] | undefined {
+ let low = 0;
+ let high = groups.length;
+ while (low < high) {
+ const mid = (low + high) >> 1;
+ if (groups[mid].anchors[0] <= date) low = mid + 1;
+ else high = mid;
+ }
+ const group = groups[low - 1];
+ if (!group) return undefined;
+ const cut = insertionPoint(group.anchors, date);
+ if (group.anchors[cut] !== date) return undefined;
+ const leftAnchors = group.anchors.slice(0, cut);
+ const rightAnchors = group.anchors.slice(cut + 1);
+ const split: Group[] = [];
+ if (leftAnchors.length)
+ split.push({
+ anchors: leftAnchors,
+ startIndex: group.startIndex,
+ endIndex: extendEnd(
+ context.calendarIndex.get(leftAnchors[leftAnchors.length - 1])!,
+ context,
+ ),
+ });
+ if (rightAnchors.length)
+ split.push({
+ anchors: rightAnchors,
+ startIndex: extendStart(context.calendarIndex.get(rightAnchors[0])!, context),
+ endIndex: group.endIndex,
+ });
+ return [...groups.slice(0, low - 1), ...split, ...groups.slice(low)];
+}
+
+function occupancyOf(
+ placements: ReadonlyMap,
+ context: ObservationCalendar,
+): Occupancy {
+ // Walk the record chain up to the nearest materialized ancestor, then derive back down.
+ const chain: [ReadonlyMap, Derivation][] = [];
+ let current = placements;
+ let occupancy: Occupancy | undefined;
+ for (;;) {
+ const known = occupancies.get(current);
+ if (known?.calendar === context) {
+ occupancy = known;
+ break;
+ }
+ const derivation = derivations.get(current);
+ if (!derivation) break;
+ chain.push([current, derivation]);
+ current = derivation.parent;
+ }
+ if (!occupancy) {
+ occupancy = occupancyFromScratch(current, context);
+ occupancies.set(current, occupancy);
+ }
+ for (let i = chain.length - 1; i >= 0; i--) {
+ const [map, derivation] = chain[i];
+ derivations.delete(map);
+ occupancy =
+ deriveOccupancy(occupancy, derivation, context) ?? occupancyFromScratch(map, context);
+ occupancies.set(map, occupancy);
+ }
+ return occupancy;
+}
+
+function tieKeyOf(occupancy: Occupancy): string {
+ return (occupancy.tieKey ??= occupancy.dates.join('|'));
+}
+
+export function placementTieKey(state: ObservedPlan, context: ObservationCalendar): string {
+ const occupancy = occupancyOf(state.placements, context);
+ if (shadowMode) verifyOccupancy(state, context, occupancy);
+ return tieKeyOf(occupancy);
}
export function expiryTieKey(state: ObservedPlan): string {
@@ -108,8 +381,33 @@ 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])]
+function extendStart(index: number, context: ObservationCalendar): number {
+ while (
+ context.declaredDates.has(calendarDateAt(index - 1, context)) ||
+ isNaturalNonWorkingDate(calendarDateAt(index - 1, context), context)
+ ) {
+ context.check();
+ index--;
+ }
+ return index;
+}
+
+function extendEnd(index: number, context: ObservationCalendar): number {
+ while (
+ context.declaredDates.has(calendarDateAt(index + 1, context)) ||
+ isNaturalNonWorkingDate(calendarDateAt(index + 1, context), context)
+ ) {
+ context.check();
+ index++;
+ }
+ return index;
+}
+
+function groupsFromScratch(
+ placementDates: Iterable,
+ context: ObservationCalendar,
+): Group[] {
+ const dates = [...new Set([...placementDates, ...context.declaredDates])]
.filter((date) => date >= context.horizon.start && date < context.horizon.end)
.sort(compareDate);
if (!dates.length) return [];
@@ -130,23 +428,11 @@ function periodBounds(state: ObservedPlan, context: ObservationCalendar) {
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 };
+ return {
+ anchors,
+ startIndex: extendStart(context.calendarIndex.get(anchors[0])!, context),
+ endIndex: extendEnd(context.calendarIndex.get(anchors[anchors.length - 1])!, context),
+ };
});
}
@@ -160,7 +446,7 @@ function calendarPeriod(
periods = new Map();
calendarPeriodCache.set(context, periods);
}
- const key = `${startIndex}:${endIndex}`;
+ const key = startIndex * 1_048_576 + endIndex;
let period = periods.get(key);
if (period) periods.delete(key);
else {
@@ -185,14 +471,18 @@ function calendarPeriod(
return period;
}
-export function periodData(state: ObservedPlan, context: ObservationCalendar): TimeOffPeriod[] {
- return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => {
- const period = calendarPeriod(startIndex, endIndex, context);
+function periodsOf(
+ state: ObservedPlan,
+ groups: readonly Group[],
+ context: ObservationCalendar,
+): TimeOffPeriod[] {
+ return groups.map((group) => {
+ const period = (group.period ??= calendarPeriod(group.startIndex, group.endIndex, context));
return {
start: period.start,
end: period.end,
duration: period.duration,
- placementDates: anchors.filter((date) => state.placements.has(date)),
+ placementDates: group.anchors.filter((date) => state.placements.has(date)),
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
nonWorkingDates: [...period.nonWorkingDates],
nonWorkingDayGain: period.nonWorkingDates.length,
@@ -200,6 +490,110 @@ export function periodData(state: ObservedPlan, context: ObservationCalendar): T
});
}
+export function periodData(state: ObservedPlan, context: ObservationCalendar): TimeOffPeriod[] {
+ const occupancy = occupancyOf(state.placements, context);
+ if (shadowMode) verifyOccupancy(state, context, occupancy);
+ return periodsOf(state, occupancy.groups, context);
+}
+
+/** Reference computation, ignoring every cache: the test oracle and the shadow-mode baseline. */
+export function observationsFromScratch(state: ObservedPlan, context: ObservationCalendar) {
+ const dates = [...state.placements.keys()].sort();
+ const groups = groupsFromScratch(dates, context);
+ return {
+ tieKey: dates.join('|'),
+ groups: groups.map(({ anchors, startIndex, endIndex }) => ({
+ anchors,
+ startIndex,
+ endIndex,
+ })),
+ periods: periodsOf(state, groups, context),
+ ...calendarObservations(state, groups, context),
+ };
+}
+
+function holidayWeeksFor(state: ObservedPlan, context: ObservationCalendar) {
+ return Object.freeze(
+ context.holidayWeeks.map((week) =>
+ Object.freeze({
+ value: week.value,
+ complete: week.dates.every(
+ (date) => state.placements.has(date) || context.declaredDates.has(date),
+ ),
+ }),
+ ),
+ );
+}
+
+function timeOffPeriodsFor(
+ groups: readonly Group[],
+ context: ObservationCalendar,
+ periods?: TimeOffPeriod[],
+) {
+ return Object.freeze(
+ periods
+ ? periods.map((period) =>
+ Object.freeze({ nonWorkingDates: Object.freeze([...period.nonWorkingDates]) }),
+ )
+ : groups.map(
+ (group) =>
+ (group.timeOff ??= Object.freeze({
+ nonWorkingDates: (group.period ??= calendarPeriod(
+ group.startIndex,
+ group.endIndex,
+ context,
+ )).nonWorkingDates,
+ })),
+ ),
+ );
+}
+
+function workSequencesFor(groups: readonly Group[], context: ObservationCalendar) {
+ return Object.freeze(
+ workSequenceLengths(groups, context).map((sequence) => Object.freeze(sequence)),
+ );
+}
+
+function calendarObservations(
+ state: ObservedPlan,
+ groups: readonly Group[],
+ context: ObservationCalendar,
+) {
+ return {
+ publicHolidayWeeks: holidayWeeksFor(state, context),
+ timeOffPeriods: timeOffPeriodsFor(groups, context),
+ workSequences: workSequencesFor(groups, context),
+ };
+}
+
+function verifyOccupancy(
+ state: ObservedPlan,
+ context: ObservationCalendar,
+ occupancy: Occupancy,
+): void {
+ const expected = observationsFromScratch(state, context);
+ const fail = (field: string, got: unknown, want: unknown): never => {
+ throw new Error(
+ `Observation shadow mismatch on ${field} for placements [${[...state.placements.keys()].sort().join(', ')}]: got ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`,
+ );
+ };
+ if (tieKeyOf(occupancy) !== expected.tieKey)
+ fail('tieKey', tieKeyOf(occupancy), expected.tieKey);
+ const groups = occupancy.groups.map(({ anchors, startIndex, endIndex }) => ({
+ anchors,
+ startIndex,
+ endIndex,
+ }));
+ if (JSON.stringify(groups) !== JSON.stringify(expected.groups))
+ fail('groups', groups, expected.groups);
+ for (const field of ['publicHolidayWeeks', 'timeOffPeriods', 'workSequences'] as const) {
+ const cached = occupancy.observed[field];
+ // Caller-supplied periods are never compared: they are not derived from the key set.
+ if (cached && JSON.stringify(cached) !== JSON.stringify(expected[field]))
+ fail(field, cached, expected[field]);
+ }
+}
+
export function optimizationBalanceObservations(state: ObservedPlan, context: ObservationCalendar) {
return state.balances.map((balance) => ({
unplacedQuantity: balance.remaining,
@@ -214,60 +608,36 @@ export function optimizationObservations(
): 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 occupancy = occupancyOf(state.placements, context);
+ const calendar = occupancy.observed;
+ if (shadowMode) {
+ verifyOccupancy(state, context, occupancy);
+ calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context);
+ calendar.workSequences ??= workSequencesFor(occupancy.groups, context);
+ if (!periods) calendar.timeOffPeriods ??= timeOffPeriodsFor(occupancy.groups, context);
+ verifyOccupancy(state, context, occupancy);
}
- 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 | 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),
- ),
- }),
- ),
- ));
+ return (calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context));
},
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,
- }),
- ),
+ return (calendar.timeOffPeriods ??= timeOffPeriodsFor(
+ occupancy.groups,
+ context,
+ periods,
));
},
get workSequences() {
- return (calendar.workSequences ??= Object.freeze(
- workSequenceLengths((bounds ??= periodBounds(state, context)), context).map(
- (sequence) => Object.freeze(sequence),
- ),
- ));
+ return (calendar.workSequences ??= workSequencesFor(occupancy.groups, context));
},
leavePlacements: [...state.placements.values()],
};
}
function workSequenceLengths(
- bounds: ReturnType,
+ bounds: readonly Group[],
context: ObservationCalendar,
): OptimizationObservations['workSequences'] {
if (!context.workingDays.length) return [];