refactor(planning): consolidate plan observations in one module
Move time-off periods, work sequences, public-holiday weeks, balance observations, the placement list and both tie keys into plan-observations.ts. The code moves without behavior changes; all seven benchmark scenarios produce byte-identical outcomes, explanations and final states, and the frozen-engine check passes. Benchmark timings stay within noise. Refs #282 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
24c35542a8
commit
87d72064be
3 files changed
+384
-281
No files matched your search
@@ -0,0 +1,47 @@
|
||||
# Issue #279: incremental exact scoring
|
||||
|
||||
Step-by-step record for [issue #279](https://gitea.nessar.fr/nessar/vac-optimizer/issues/279).
|
||||
Each step keeps planning outcomes, stable explanations and final states identical
|
||||
to develop after PR #278 (`a871f8f`). The
|
||||
[frozen-engine differential check](README.md#frozen-engine-differential-check)
|
||||
guards every step.
|
||||
|
||||
All measurements use Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the development
|
||||
laptop, with no other test, build or profiling workload running alongside the
|
||||
protocol. Only same-session before/after pairs are comparable.
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||
```
|
||||
|
||||
The per-scenario figures below are the means of the two measured calculations.
|
||||
|
||||
## Baseline profile
|
||||
|
||||
A CPU profile of the bundled core over all seven scenarios, with
|
||||
`calculationDate: '2024-01-01'`, put 50.5 of 67.0 loaded-year seconds in
|
||||
candidate evaluation (`evaluateRuleCandidate`). Of those, 41.3 seconds went to
|
||||
active-objective evaluation: 15.6 seconds in time-off-period observations,
|
||||
11.5 seconds in building observations and 7.6 seconds in sorting placement dates
|
||||
for the placement tie key and the observation cache key. The asynchronous
|
||||
adapter's 1 ms timer yields cost about 7% on typed obligations.
|
||||
|
||||
## #282: consolidate plan observations
|
||||
|
||||
Time-off periods, work sequences, public-holiday weeks, balance observations,
|
||||
the placement list and both tie keys now live in
|
||||
`src/app/services/plan-observations.ts`. The code moved without behavior
|
||||
changes. All seven scenarios produce byte-identical outcomes, explanations and
|
||||
final states compared with `a871f8f`.
|
||||
|
||||
| Measurement | Before | After |
|
||||
| ------------------- | --------: | --------: |
|
||||
| Vitest duration | 262.000 s | 262.030 s |
|
||||
| Loaded year | 66.010 s | 66.497 s |
|
||||
| Typed obligations | 4.887 s | 4.964 s |
|
||||
| Two years | 2.914 s | 2.977 s |
|
||||
| Three years | 10.203 s | 10.049 s |
|
||||
| Representative year | 0.557 s | 0.569 s |
|
||||
|
||||
The differences are within run-to-run noise. The functional suite passes
|
||||
666 tests across 34 files, including the frozen-engine check.
|
||||
@@ -2,8 +2,21 @@ import {
|
||||
DeclaredNonWorkingDay,
|
||||
parseDeclaredDays,
|
||||
frenchPublicHolidaysForYear,
|
||||
isWorkingPlanningDate,
|
||||
} from './declared-non-working-days';
|
||||
import {
|
||||
addDays,
|
||||
compareDate,
|
||||
dateRange,
|
||||
evaluateStateOptimization,
|
||||
expiryTieKey,
|
||||
isAtRisk,
|
||||
optimizationBalanceObservations,
|
||||
optimizationObservations,
|
||||
periodData,
|
||||
placementTieKey,
|
||||
utcDate,
|
||||
workSequenceDates,
|
||||
} from './plan-observations';
|
||||
import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date';
|
||||
import {
|
||||
compareObjectiveLists,
|
||||
@@ -501,21 +514,6 @@ const occurrenceCache = new WeakMap<SearchContext, Map<string, string[]>>();
|
||||
const calendarPatternCache = new WeakMap<SearchContext, Map<string, string[][]>>();
|
||||
const preferencePatternCache = new WeakMap<SearchContext, Map<string, string[][][]>>();
|
||||
const declaredWindowCache = new WeakMap<SearchContext, Map<string, DeclaredNonWorkingDay[]>>();
|
||||
type CalendarOptimizationObservations = Partial<
|
||||
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
|
||||
>;
|
||||
const calendarObservationCache = new WeakMap<
|
||||
SearchContext,
|
||||
Map<string, CalendarOptimizationObservations>
|
||||
>();
|
||||
interface CalendarPeriod {
|
||||
start: string;
|
||||
end: string;
|
||||
duration: number;
|
||||
nonWorkingDates: readonly string[];
|
||||
declaredDates: readonly string[];
|
||||
}
|
||||
const calendarPeriodCache = new WeakMap<SearchContext, Map<string, CalendarPeriod>>();
|
||||
|
||||
interface SearchContext {
|
||||
declaredDays: readonly DeclaredNonWorkingDay[];
|
||||
@@ -587,7 +585,6 @@ class UserCancelled extends Error {}
|
||||
// A true value asks the asynchronous driver to pause immediately at a phase boundary.
|
||||
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
// ponytail: cap the number of feasible obligation branches previewed for a
|
||||
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
|
||||
// that this ceiling misses a better lexicographic assignment.
|
||||
@@ -635,29 +632,10 @@ function recordValue(value: unknown): RuntimeRecord | undefined {
|
||||
return typeof value === 'object' && value !== null ? (value as RuntimeRecord) : undefined;
|
||||
}
|
||||
|
||||
function utcDate(key: string): Date {
|
||||
return new Date(`${key}T00:00:00Z`);
|
||||
}
|
||||
|
||||
function addDays(key: string, days: number): string {
|
||||
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function compareDate(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
function compareText(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function defaultHorizon(start: string): { start: string; end: string } {
|
||||
return { start, end: addPlanningYears(start, 1) };
|
||||
}
|
||||
@@ -3318,20 +3296,6 @@ function preferenceStateKey(state: State): string {
|
||||
]);
|
||||
}
|
||||
|
||||
function placementTieKey(state: State): string {
|
||||
return [...state.placements.keys()].sort().join('|');
|
||||
}
|
||||
|
||||
function expiryTieKey(state: State): string {
|
||||
return [...state.placements.values()]
|
||||
.map(
|
||||
(placement) =>
|
||||
state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? '',
|
||||
)
|
||||
.sort(compareDate)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function activeObjectiveVector(
|
||||
state: State,
|
||||
context: SearchContext,
|
||||
@@ -3418,237 +3382,6 @@ function compareObligationPreviews(
|
||||
);
|
||||
}
|
||||
|
||||
function isAtRisk(balance: InternalBalance, context: SearchContext): boolean {
|
||||
return (
|
||||
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
|
||||
compareDate(balance.expiryDate, context.horizon.end) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
function isNaturalNonWorkingDate(date: string, context: SearchContext): boolean {
|
||||
return context.calendarIndex.has(date)
|
||||
? !context.workingCalendar.has(date)
|
||||
: !isWorkingPlanningDate(date);
|
||||
}
|
||||
|
||||
function calendarDateAt(index: number, context: SearchContext): string {
|
||||
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
|
||||
}
|
||||
|
||||
function periodBounds(state: State, context: SearchContext) {
|
||||
const dates = [...new Set([...state.placements.keys(), ...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();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
function calendarPeriod(
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
context: SearchContext,
|
||||
): CalendarPeriod {
|
||||
let periods = calendarPeriodCache.get(context);
|
||||
if (!periods) {
|
||||
periods = new Map();
|
||||
calendarPeriodCache.set(context, periods);
|
||||
}
|
||||
const key = `${startIndex}:${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 periodData(state: State, context: SearchContext): TimeOffPeriod[] {
|
||||
return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => {
|
||||
const period = calendarPeriod(startIndex, endIndex, context);
|
||||
return {
|
||||
start: period.start,
|
||||
end: period.end,
|
||||
duration: period.duration,
|
||||
placementDates: anchors.filter((date) => state.placements.has(date)),
|
||||
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
|
||||
nonWorkingDates: [...period.nonWorkingDates],
|
||||
nonWorkingDayGain: period.nonWorkingDates.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function optimizationBalanceObservations(state: State, context: SearchContext) {
|
||||
return state.balances.map((balance) => ({
|
||||
unplacedQuantity: balance.remaining,
|
||||
atRisk: isAtRisk(balance, context),
|
||||
}));
|
||||
}
|
||||
|
||||
function optimizationObservations(
|
||||
state: State,
|
||||
context: SearchContext,
|
||||
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 key = placementTieKey(state);
|
||||
let byDates = calendarObservationCache.get(context);
|
||||
if (!byDates) {
|
||||
byDates = new Map();
|
||||
calendarObservationCache.set(context, byDates);
|
||||
}
|
||||
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<typeof periodBounds> | 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),
|
||||
),
|
||||
}),
|
||||
),
|
||||
));
|
||||
},
|
||||
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,
|
||||
}),
|
||||
),
|
||||
));
|
||||
},
|
||||
get workSequences() {
|
||||
return (calendar.workSequences ??= Object.freeze(
|
||||
workSequenceLengths((bounds ??= periodBounds(state, context)), context).map(
|
||||
(sequence) => Object.freeze(sequence),
|
||||
),
|
||||
));
|
||||
},
|
||||
leavePlacements: [...state.placements.values()],
|
||||
};
|
||||
}
|
||||
|
||||
function workSequenceLengths(
|
||||
bounds: ReturnType<typeof periodBounds>,
|
||||
context: SearchContext,
|
||||
): 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;
|
||||
}
|
||||
|
||||
function workSequenceDates(state: State, context: SearchContext) {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
function evaluateStateOptimization(
|
||||
configuration: OptimizationConfiguration,
|
||||
state: State,
|
||||
context: SearchContext,
|
||||
observations = optimizationObservations(state, context),
|
||||
): number {
|
||||
return evaluateOptimization(configuration, observations);
|
||||
}
|
||||
|
||||
function qualityMetrics(
|
||||
state: State,
|
||||
context: SearchContext,
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
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'>
|
||||
>;
|
||||
|
||||
const calendarObservationCache = new WeakMap<
|
||||
ObservationCalendar,
|
||||
Map<string, CalendarOptimizationObservations>
|
||||
>();
|
||||
|
||||
interface CalendarPeriod {
|
||||
start: string;
|
||||
end: string;
|
||||
duration: number;
|
||||
nonWorkingDates: readonly string[];
|
||||
declaredDates: readonly string[];
|
||||
}
|
||||
|
||||
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<string, CalendarPeriod>>();
|
||||
|
||||
export function placementTieKey(state: ObservedPlan): string {
|
||||
return [...state.placements.keys()].sort().join('|');
|
||||
}
|
||||
|
||||
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 periodBounds(state: ObservedPlan, context: ObservationCalendar) {
|
||||
const dates = [...new Set([...state.placements.keys(), ...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();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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}:${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;
|
||||
}
|
||||
|
||||
export function periodData(state: ObservedPlan, context: ObservationCalendar): TimeOffPeriod[] {
|
||||
return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => {
|
||||
const period = calendarPeriod(startIndex, endIndex, context);
|
||||
return {
|
||||
start: period.start,
|
||||
end: period.end,
|
||||
duration: period.duration,
|
||||
placementDates: anchors.filter((date) => state.placements.has(date)),
|
||||
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
|
||||
nonWorkingDates: [...period.nonWorkingDates],
|
||||
nonWorkingDayGain: period.nonWorkingDates.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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 key = placementTieKey(state);
|
||||
let byDates = calendarObservationCache.get(context);
|
||||
if (!byDates) {
|
||||
byDates = new Map();
|
||||
calendarObservationCache.set(context, byDates);
|
||||
}
|
||||
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<typeof periodBounds> | 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),
|
||||
),
|
||||
}),
|
||||
),
|
||||
));
|
||||
},
|
||||
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,
|
||||
}),
|
||||
),
|
||||
));
|
||||
},
|
||||
get workSequences() {
|
||||
return (calendar.workSequences ??= Object.freeze(
|
||||
workSequenceLengths((bounds ??= periodBounds(state, context)), context).map(
|
||||
(sequence) => Object.freeze(sequence),
|
||||
),
|
||||
));
|
||||
},
|
||||
leavePlacements: [...state.placements.values()],
|
||||
};
|
||||
}
|
||||
|
||||
function workSequenceLengths(
|
||||
bounds: ReturnType<typeof periodBounds>,
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user