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>
199 lines
7.0 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|