Files
vac-optimizer/src/app/services/plan-observations.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

694 lines
21 KiB
TypeScript

import { isWorkingPlanningDate } from './declared-non-working-days';
import type { TimeOffPeriod } from './leave-planner.core';
import {
evaluateOptimization,
type OptimizationConfiguration,
type OptimizationObservations,
} from './planning-objectives';
// Everything planning objectives observe of a plan: periods, work sequences, holiday weeks, balances, placements and tie keys.
export interface ObservedBalance {
id: string;
remaining: number;
expiryDate: string;
}
export interface ObservedPlacement {
date: string;
balanceId: string;
supporting: boolean;
}
export interface ObservedPlan<P extends ObservedPlacement = ObservedPlacement> {
balances: readonly ObservedBalance[];
placements: ReadonlyMap<string, P>;
}
export interface ObservationCalendar {
horizon: { start: string; end: string };
declaredDates: ReadonlySet<string>;
holidayWeeks: readonly { dates: readonly string[]; value: number }[];
calendarDays: readonly string[];
calendarIndex: ReadonlyMap<string, number>;
workingCalendar: ReadonlySet<string>;
workingPrefix: readonly number[];
workingDays: readonly string[];
check: () => void;
}
const DAY_MS = 86_400_000;
export function utcDate(key: string): Date {
return new Date(`${key}T00:00:00Z`);
}
export function addDays(key: string, days: number): string {
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
}
export function compareDate(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
export function dateRange(start: string, endExclusive: string): string[] {
const result: string[] = [];
for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1))
result.push(date);
return result;
}
type CalendarOptimizationObservations = Partial<
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
>;
interface CalendarPeriod {
start: string;
end: string;
duration: number;
nonWorkingDates: readonly string[];
declaredDates: readonly string[];
}
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<number, CalendarPeriod>>();
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 {
return [...state.placements.values()]
.map(
(placement) =>
state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? '',
)
.sort(compareDate)
.join('|');
}
export function isAtRisk(balance: ObservedBalance, context: ObservationCalendar): boolean {
return (
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
compareDate(balance.expiryDate, context.horizon.end) <= 0
);
}
function isNaturalNonWorkingDate(date: string, context: ObservationCalendar): boolean {
return context.calendarIndex.has(date)
? !context.workingCalendar.has(date)
: !isWorkingPlanningDate(date);
}
function calendarDateAt(index: number, context: ObservationCalendar): string {
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
}
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 [];
const groups: string[][] = [];
let group = [dates[0]];
for (let i = 1; i < dates.length; i++) {
context.check();
const previousIndex = context.calendarIndex.get(dates[i - 1])!;
const currentIndex = context.calendarIndex.get(dates[i])!;
const workingInGap =
context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1];
if (workingInGap === 0) group.push(dates[i]);
else {
groups.push(group);
group = [dates[i]];
}
}
groups.push(group);
return groups.map((anchors) => {
context.check();
return {
anchors,
startIndex: extendStart(context.calendarIndex.get(anchors[0])!, context),
endIndex: extendEnd(context.calendarIndex.get(anchors[anchors.length - 1])!, context),
};
});
}
function calendarPeriod(
startIndex: number,
endIndex: number,
context: ObservationCalendar,
): CalendarPeriod {
let periods = calendarPeriodCache.get(context);
if (!periods) {
periods = new Map();
calendarPeriodCache.set(context, periods);
}
const key = startIndex * 1_048_576 + endIndex;
let period = periods.get(key);
if (period) periods.delete(key);
else {
const start = calendarDateAt(startIndex, context);
const end = calendarDateAt(endIndex, context);
const dates =
startIndex >= 0 && endIndex < context.calendarDays.length
? context.calendarDays.slice(startIndex, endIndex + 1)
: dateRange(start, addDays(end, 1));
period = Object.freeze({
start,
end,
duration: dates.length,
nonWorkingDates: Object.freeze(
dates.filter((date) => isNaturalNonWorkingDate(date, context)),
),
declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))),
});
}
periods.set(key, period);
if (periods.size > 4096) periods.delete(periods.keys().next().value!);
return period;
}
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: group.anchors.filter((date) => state.placements.has(date)),
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
nonWorkingDates: [...period.nonWorkingDates],
nonWorkingDayGain: period.nonWorkingDates.length,
};
});
}
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,
atRisk: isAtRisk(balance, context),
}));
}
export function optimizationObservations(
state: ObservedPlan,
context: ObservationCalendar,
periods?: TimeOffPeriod[],
): OptimizationObservations {
// Calendar facts depend only on occupied dates within this immutable request.
// Balance quantities, provenance and supporting status stay outside this cache.
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);
}
return {
leaveBalances: optimizationBalanceObservations(state, context),
get publicHolidayWeeks() {
return (calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context));
},
get timeOffPeriods() {
return (calendar.timeOffPeriods ??= timeOffPeriodsFor(
occupancy.groups,
context,
periods,
));
},
get workSequences() {
return (calendar.workSequences ??= workSequencesFor(occupancy.groups, context));
},
leavePlacements: [...state.placements.values()],
};
}
function workSequenceLengths(
bounds: readonly Group[],
context: ObservationCalendar,
): OptimizationObservations['workSequences'] {
if (!context.workingDays.length) return [];
let nextIndex = context.calendarIndex.get(context.horizon.start)!;
const sequences = bounds.map(({ anchors }, index) => {
const previousIndex = nextIndex;
nextIndex = context.calendarIndex.get(anchors[anchors.length - 1])! + 1;
return {
length:
context.workingPrefix[context.calendarIndex.get(anchors[0])!] -
context.workingPrefix[previousIndex],
internal: index > 0,
};
});
sequences.push({
length:
context.workingPrefix[context.calendarIndex.get(context.horizon.end)!] -
context.workingPrefix[nextIndex],
internal: false,
});
return sequences;
}
export function workSequenceDates(state: ObservedPlan, context: ObservationCalendar) {
const sequences: string[][] = [];
let current: string[] = [];
let inTimeOffPeriod = false;
for (const date of context.workingDays) {
context.check();
if (state.placements.has(date) || context.declaredDates.has(date)) {
if (!inTimeOffPeriod) sequences.push(current);
current = [];
inTimeOffPeriod = true;
} else {
current.push(date);
inTimeOffPeriod = false;
}
}
if (context.workingDays.length) sequences.push(current);
return sequences.map((dates, index) => ({
dates,
internal: index > 0 && index < sequences.length - 1,
}));
}
export function evaluateStateOptimization(
configuration: OptimizationConfiguration,
state: ObservedPlan,
context: ObservationCalendar,
observations = optimizationObservations(state, context),
): number {
return evaluateOptimization(configuration, observations);
}