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 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-17 00:45:28 +02:00
1 parent 87d72064be
commit 7890a03a74
6 files changed
+717 -89

No files matched your search

+6 -1
View File
@@ -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=="],
+39
View File
@@ -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.
+1
View File
@@ -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",
+32 -17
View File
@@ -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;
+198
View File
@@ -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<string>();
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<string, number>();
const workingCalendar = new Set<string>();
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<string, { dates: string[]; value: number }>();
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<Op['kind']>('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<string, ObservedPlacement>;
// 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);
}
});
});
+441 -71
View File
@@ -62,11 +62,6 @@ type CalendarOptimizationObservations = Partial<
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
>;
const calendarObservationCache = new WeakMap<
ObservationCalendar,
Map<string, CalendarOptimizationObservations>
>();
interface CalendarPeriod {
start: string;
end: string;
@@ -75,10 +70,288 @@ interface CalendarPeriod {
declaredDates: readonly string[];
}
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<string, CalendarPeriod>>();
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<number, CalendarPeriod>>();
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<string, unknown>;
op: 'same' | 'add' | 'remove';
date?: string;
}
const occupancies = new WeakMap<ReadonlyMap<string, unknown>, Occupancy>();
const derivations = new WeakMap<ReadonlyMap<string, unknown>, Derivation>();
let shadowMode =
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[
'VAC_OBSERVATION_SHADOW'
] === '1';
export function setObservationShadowMode(enabled: boolean): void {
shadowMode = enabled;
}
function note(
parent: ReadonlyMap<string, unknown>,
child: ReadonlyMap<string, unknown>,
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<string, unknown>,
child: ReadonlyMap<string, unknown>,
): 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<string, unknown>,
child: ReadonlyMap<string, unknown>,
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<string, unknown>,
child: ReadonlyMap<string, unknown>,
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<string, unknown>,
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<ObservationCalendar, Map<string, number[]>>();
function derivedHolidayWeeks(
weeks: NonNullable<CalendarOptimizationObservations['publicHolidayWeeks']>,
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<string, unknown>,
context: ObservationCalendar,
): Occupancy {
// Walk the record chain up to the nearest materialized ancestor, then derive back down.
const chain: [ReadonlyMap<string, unknown>, 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<string>,
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<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),
),
}),
),
));
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<typeof periodBounds>,
bounds: readonly Group[],
context: ObservationCalendar,
): OptimizationObservations['workSequences'] {
if (!context.workingDays.length) return [];