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>
This commit is contained in:
1 parent
87d72064be
commit
7890a03a74
6 files changed
+717
-89
No files matched your search
@@ -42,6 +42,7 @@
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"fast-check": "4.10.2",
|
||||
"jest": "^30.4.2",
|
||||
"jest-preset-angular": "^17.0.0",
|
||||
"jsdom": "^30.0.0",
|
||||
@@ -1405,6 +1406,8 @@
|
||||
|
||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
|
||||
"fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
@@ -1969,7 +1972,7 @@
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
|
||||
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
|
||||
|
||||
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
@@ -2949,6 +2952,8 @@
|
||||
|
||||
"jest-circus/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="],
|
||||
|
||||
"jest-circus/pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
|
||||
|
||||
"jest-cli/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="],
|
||||
|
||||
"jest-cli/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
@@ -45,3 +45,42 @@ final states compared with `a871f8f`.
|
||||
|
||||
The differences are within run-to-run noise. The functional suite passes
|
||||
666 tests across 34 files, including the frozen-engine check.
|
||||
|
||||
## #283: incremental plan observations
|
||||
|
||||
Each placements map now carries an immutable occupancy: its sorted placement
|
||||
dates, time-off period groups, public-holiday-week completeness and lazily
|
||||
computed calendar observations. A candidate derived from its parent by one added
|
||||
or removed date, or by a copy with the same dates, updates only the affected
|
||||
groups and holiday weeks. Unchanged groups keep their cached periods and
|
||||
observation objects. Maps built from snapshots, maps observed against another
|
||||
calendar and dates that are not in-horizon working days fall back to the
|
||||
from-scratch computation.
|
||||
|
||||
The from-scratch computation remains the oracle. A fast-check property test
|
||||
applies random add, remove and retype sequences across year boundaries, horizon
|
||||
edges, declared days, weekends and public holidays, and compares every step with
|
||||
it. An opt-in shadow mode (`VAC_OBSERVATION_SHADOW=1` or
|
||||
`setObservationShadowMode(true)`) compares both on every observation and throws
|
||||
on any mismatch. It passed across the frozen-engine check and all seven
|
||||
benchmark scenarios, whose outcomes, explanations and final states stay
|
||||
byte-identical.
|
||||
|
||||
| Measurement | Before | After |
|
||||
| ------------------- | --------: | --------: |
|
||||
| Vitest duration | 262.030 s | 193.680 s |
|
||||
| Loaded year | 66.497 s | 48.489 s |
|
||||
| Typed obligations | 4.964 s | 4.947 s |
|
||||
| Two years | 2.977 s | 1.906 s |
|
||||
| Three years | 10.049 s | 6.152 s |
|
||||
| Representative year | 0.569 s | 0.441 s |
|
||||
|
||||
A fresh loaded-year CPU profile of the bundled core took 46.8 seconds. Of the
|
||||
1.68 million observed placement maps, one was built from scratch; the rest were
|
||||
derived. Observations now take about 4 seconds. Candidate evaluation still
|
||||
dominates: 24.3 seconds in `evaluateRuleCandidate`, including 15.2 seconds of
|
||||
active objectives and 6.9 seconds of candidate finalization, plus 6.2 seconds
|
||||
assigning dates and about 5 seconds each of garbage collection and timer idle
|
||||
time.
|
||||
|
||||
The functional suite passes 669 tests across 35 files.
|
||||
@@ -64,6 +64,7 @@
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"fast-check": "4.10.2",
|
||||
"jest": "^30.4.2",
|
||||
"jest-preset-angular": "^17.0.0",
|
||||
"jsdom": "^30.0.0",
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
optimizationBalanceObservations,
|
||||
optimizationObservations,
|
||||
periodData,
|
||||
derivePlacements,
|
||||
notePlacementAdded,
|
||||
notePlacementRemoved,
|
||||
placementTieKey,
|
||||
utcDate,
|
||||
workSequenceDates,
|
||||
@@ -698,19 +701,21 @@ function reason(code: string, message: string, ruleIds?: string[]): PlanningReas
|
||||
}
|
||||
|
||||
function cloneState(state: State, placements?: State['placements']): State {
|
||||
const clonedPlacements =
|
||||
placements ??
|
||||
new Map(
|
||||
[...state.placements.entries()].map(([date, placement]) => [
|
||||
date,
|
||||
{
|
||||
...placement,
|
||||
ruleIds: new Set(placement.ruleIds),
|
||||
},
|
||||
]),
|
||||
);
|
||||
derivePlacements(state.placements, clonedPlacements);
|
||||
return {
|
||||
balances: state.balances.map((balance) => ({ ...balance })),
|
||||
placements:
|
||||
placements ??
|
||||
new Map(
|
||||
[...state.placements.entries()].map(([date, placement]) => [
|
||||
date,
|
||||
{
|
||||
...placement,
|
||||
ruleIds: new Set(placement.ruleIds),
|
||||
},
|
||||
]),
|
||||
),
|
||||
placements: clonedPlacements,
|
||||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||||
...activation,
|
||||
dates: [...activation.dates],
|
||||
@@ -1460,6 +1465,7 @@ function assignDateVariants(
|
||||
const result = cloneState(state);
|
||||
const target = result.balances.find((item) => item.id === balance.id)!;
|
||||
target.remaining--;
|
||||
notePlacementAdded(state.placements, result.placements, date);
|
||||
result.placements.set(date, {
|
||||
date,
|
||||
balanceId: target.id,
|
||||
@@ -1498,6 +1504,7 @@ function assignDateToBalance(
|
||||
const result = cloneState(state, new Map(state.placements));
|
||||
const target = result.balances.find((item) => item.id === balanceId)!;
|
||||
target.remaining--;
|
||||
notePlacementAdded(state.placements, result.placements, date);
|
||||
result.placements.set(date, {
|
||||
date,
|
||||
balanceId,
|
||||
@@ -1838,6 +1845,7 @@ function releasePlacement(state: State, date: string): State {
|
||||
})),
|
||||
};
|
||||
const placement = result.placements.get(date)!;
|
||||
notePlacementRemoved(state.placements, result.placements, date);
|
||||
result.placements.delete(date);
|
||||
result.balances.find((balance) => balance.id === placement.balanceId)!.remaining++;
|
||||
return result;
|
||||
@@ -2811,7 +2819,7 @@ function* optimizeRulePlacements(
|
||||
moved &&
|
||||
dateInvariantObjectives &&
|
||||
compareObjectiveLists(
|
||||
tieBreakObjectives(moved),
|
||||
tieBreakObjectives(moved, context),
|
||||
beforeVector.objectives.slice(-2),
|
||||
) >= 0
|
||||
) {
|
||||
@@ -3346,7 +3354,7 @@ function* activeObjectives(
|
||||
}
|
||||
}
|
||||
|
||||
function tieBreakObjectives(state: State): PlanningObjective[] {
|
||||
function tieBreakObjectives(state: State, context: SearchContext): PlanningObjective[] {
|
||||
return [
|
||||
{
|
||||
id: 'balance-expiry',
|
||||
@@ -3357,7 +3365,7 @@ function tieBreakObjectives(state: State): PlanningObjective[] {
|
||||
{
|
||||
id: 'placement-date',
|
||||
direction: 'minimize',
|
||||
value: placementTieKey(state),
|
||||
value: placementTieKey(state, context),
|
||||
label: 'Placement date tie-break',
|
||||
},
|
||||
];
|
||||
@@ -3367,7 +3375,10 @@ function obligationPreviewObjectives(
|
||||
preview: { state: State },
|
||||
context: SearchContext,
|
||||
): PlanningObjective[] {
|
||||
return [...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state)];
|
||||
return [
|
||||
...activeObjectiveVector(preview.state, context),
|
||||
...tieBreakObjectives(preview.state, context),
|
||||
];
|
||||
}
|
||||
|
||||
/** Compare complete previews so obligation DFS does not commit to its first feasible branch. */
|
||||
@@ -3553,7 +3564,7 @@ function objectiveVector(
|
||||
label: 'Unsatisfied obligations',
|
||||
},
|
||||
...active,
|
||||
...tieBreakObjectives(state),
|
||||
...tieBreakObjectives(state, context),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -3692,6 +3703,7 @@ function planExplanation(
|
||||
compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0
|
||||
: false;
|
||||
const withoutPlacement = cloneState(state);
|
||||
notePlacementRemoved(state.placements, withoutPlacement.placements, placement.date);
|
||||
withoutPlacement.placements.delete(placement.date);
|
||||
const released = withoutPlacement.balances.find(
|
||||
(candidate) => candidate.id === placement.balanceId,
|
||||
@@ -4975,7 +4987,10 @@ export function analyzeCounterfactualInternal(
|
||||
const baseline = cloneState(finalState);
|
||||
const removeDate = selected.date;
|
||||
const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
|
||||
if (removeDate) baseline.placements.delete(removeDate);
|
||||
if (removeDate) {
|
||||
notePlacementRemoved(finalState.placements, baseline.placements, removeDate);
|
||||
baseline.placements.delete(removeDate);
|
||||
}
|
||||
const released = baseline.balances.find((balance) => balance.id === selected.balanceId);
|
||||
if (released && removed?.balanceId === selected.balanceId) released.remaining++;
|
||||
const beforeState = baseline;
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -62,11 +62,6 @@ type CalendarOptimizationObservations = Partial<
|
||||
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
|
||||
>;
|
||||
|
||||
const calendarObservationCache = new WeakMap<
|
||||
ObservationCalendar,
|
||||
Map<string, CalendarOptimizationObservations>
|
||||
>();
|
||||
|
||||
interface CalendarPeriod {
|
||||
start: string;
|
||||
end: string;
|
||||
@@ -75,10 +70,288 @@ interface CalendarPeriod {
|
||||
declaredDates: readonly string[];
|
||||
}
|
||||
|
||||
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<string, CalendarPeriod>>();
|
||||
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<number, CalendarPeriod>>();
|
||||
|
||||
export function placementTieKey(state: ObservedPlan): string {
|
||||
return [...state.placements.keys()].sort().join('|');
|
||||
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 {
|
||||
@@ -108,8 +381,33 @@ 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])]
|
||||
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 [];
|
||||
@@ -130,23 +428,11 @@ function periodBounds(state: ObservedPlan, context: ObservationCalendar) {
|
||||
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 };
|
||||
return {
|
||||
anchors,
|
||||
startIndex: extendStart(context.calendarIndex.get(anchors[0])!, context),
|
||||
endIndex: extendEnd(context.calendarIndex.get(anchors[anchors.length - 1])!, context),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,7 +446,7 @@ function calendarPeriod(
|
||||
periods = new Map();
|
||||
calendarPeriodCache.set(context, periods);
|
||||
}
|
||||
const key = `${startIndex}:${endIndex}`;
|
||||
const key = startIndex * 1_048_576 + endIndex;
|
||||
let period = periods.get(key);
|
||||
if (period) periods.delete(key);
|
||||
else {
|
||||
@@ -185,14 +471,18 @@ function calendarPeriod(
|
||||
return period;
|
||||
}
|
||||
|
||||
export function periodData(state: ObservedPlan, context: ObservationCalendar): TimeOffPeriod[] {
|
||||
return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => {
|
||||
const period = calendarPeriod(startIndex, endIndex, context);
|
||||
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: anchors.filter((date) => state.placements.has(date)),
|
||||
placementDates: group.anchors.filter((date) => state.placements.has(date)),
|
||||
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
|
||||
nonWorkingDates: [...period.nonWorkingDates],
|
||||
nonWorkingDayGain: period.nonWorkingDates.length,
|
||||
@@ -200,6 +490,110 @@ export function periodData(state: ObservedPlan, context: ObservationCalendar): T
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -214,60 +608,36 @@ export function optimizationObservations(
|
||||
): 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 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);
|
||||
}
|
||||
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),
|
||||
),
|
||||
}),
|
||||
),
|
||||
));
|
||||
return (calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context));
|
||||
},
|
||||
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,
|
||||
}),
|
||||
),
|
||||
return (calendar.timeOffPeriods ??= timeOffPeriodsFor(
|
||||
occupancy.groups,
|
||||
context,
|
||||
periods,
|
||||
));
|
||||
},
|
||||
get workSequences() {
|
||||
return (calendar.workSequences ??= Object.freeze(
|
||||
workSequenceLengths((bounds ??= periodBounds(state, context)), context).map(
|
||||
(sequence) => Object.freeze(sequence),
|
||||
),
|
||||
));
|
||||
return (calendar.workSequences ??= workSequencesFor(occupancy.groups, context));
|
||||
},
|
||||
leavePlacements: [...state.placements.values()],
|
||||
};
|
||||
}
|
||||
|
||||
function workSequenceLengths(
|
||||
bounds: ReturnType<typeof periodBounds>,
|
||||
bounds: readonly Group[],
|
||||
context: ObservationCalendar,
|
||||
): OptimizationObservations['workSequences'] {
|
||||
if (!context.workingDays.length) return [];
|
||||
|
||||
Reference in new issue
Block a user