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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user