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>
694 lines
21 KiB
TypeScript
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);
|
|
}
|