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:
nessarandClaude Opus 5.5 committed 2026-10-17 00:45:28 +02:00
1 parent 87d72064be
commit 7890a03a74
6 files changed
+717 -89

No files matched your search

+32 -17
View File
@@ -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;