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 [];