Files
vac-optimizer/src/app/services/plan-observations.spec.ts
T
nessarandClaude Opus 5.5 7890a03a74 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>
2026-10-17 00:45:28 +02:00

199 lines
7.0 KiB
TypeScript

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);
}
});
});