perf(planning): copy only the debited balance when assigning a date

Assigning a date copied every balance and every earned activation.
Balances and activations are only edited right after a state copies
them, so the new state now shares them with its parent, except the
balance it debits. Placement copying is unchanged.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-18 20:08:12 +02:00
1 parent 9d9404c3db
commit aa6a0c6f1b
2 files changed
+67 -28

No files matched your search

+23
View File
@@ -108,3 +108,26 @@ the sum. The core therefore scores them without revalidating;
| loaded year | 48.12 | 44.03 | | loaded year | 48.12 | 44.03 |
| two years | 1.04 | 0.96 | | two years | 1.04 | 0.96 |
| three years | 3.35 | 3.03 | | three years | 3.35 | 3.03 |
### Copy only the debited balance when assigning a date
Assigning a date copied every balance and every earned activation. Balances and
activations are only edited right after a state copies them, so the new state
now shares them with its parent, except the balance it debits. Placement copying
is unchanged. The benchmark scenarios have at most six balances and few earned
activations, so the gain is within run-to-run noise.
| Scenario | Before (s) | After (s) |
| --------------------------- | ---------: | --------: |
| representative year | 0.29 | 0.28 |
| typed obligations | 3.38 | 3.27 |
| chained earned entitlements | 0.01 | 0.01 |
| deliberate conflict | 0.00 | 0.00 |
| loaded year | 43.93 | 44.05 |
| two years | 0.95 | 0.92 |
| three years | 3.00 | 2.97 |
Across the five exact steps, the same harness measured three years at
7.45 seconds before the first step and 2.97 seconds after the last, two years at
2.27 and 0.92 seconds, typed obligations at 5.11 and 3.27 seconds, and the loaded
year at 58.26 and 44.05 seconds. These figures come from different sessions.
+44 -28
View File
@@ -696,22 +696,24 @@ function reason(code: string, message: string, ruleIds?: string[]): PlanningReas
return ruleIds?.length ? { code, message, ruleIds } : { code, message }; return ruleIds?.length ? { code, message, ruleIds } : { code, message };
} }
function cloneState(state: State, placements?: State['placements']): State { function copyPlacements(state: State): State['placements'] {
const clonedPlacements = return new Map(
placements ?? [...state.placements.entries()].map(([date, placement]) => [
new Map( date,
[...state.placements.entries()].map(([date, placement]) => [ {
date, ...placement,
{ ruleIds: new Set(placement.ruleIds),
...placement, },
ruleIds: new Set(placement.ruleIds), ]),
}, );
]), }
);
derivePlacements(state.placements, clonedPlacements); function cloneState(state: State): State {
const placements = copyPlacements(state);
derivePlacements(state.placements, placements);
return { return {
balances: state.balances.map((balance) => ({ ...balance })), balances: state.balances.map((balance) => ({ ...balance })),
placements: clonedPlacements, placements,
earnedActivations: state.earnedActivations.map((activation) => ({ earnedActivations: state.earnedActivations.map((activation) => ({
...activation, ...activation,
dates: [...activation.dates], dates: [...activation.dates],
@@ -719,6 +721,16 @@ function cloneState(state: State, placements?: State['placements']): State {
}; };
} }
/**
* Balances and activations are only edited right after a state copies them, so a
* new placement shares them with its parent state, except the balance it debits.
*/
function debitBalance(state: State, balanceId: string): InternalBalance[] {
return state.balances.map((balance) =>
balance.id === balanceId ? { ...balance, remaining: balance.remaining - 1 } : balance,
);
}
function planningStateSnapshot(state: State, context: SearchContext): PlanningStateSnapshot { function planningStateSnapshot(state: State, context: SearchContext): PlanningStateSnapshot {
return { return {
calculationDate: context.request.calculationDate, calculationDate: context.request.calculationDate,
@@ -1458,18 +1470,20 @@ function assignDateVariants(
return compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id); return compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id);
}); });
return balances.map((balance) => { return balances.map((balance) => {
const result = cloneState(state); const placements = copyPlacements(state);
const target = result.balances.find((item) => item.id === balance.id)!; notePlacementAdded(state.placements, placements, date);
target.remaining--; placements.set(date, {
notePlacementAdded(state.placements, result.placements, date);
result.placements.set(date, {
date, date,
balanceId: target.id, balanceId: balance.id,
type: target.type, type: balance.type,
ruleIds: ruleId ? new Set([ruleId]) : new Set(), ruleIds: ruleId ? new Set([ruleId]) : new Set(),
supporting: false, supporting: false,
}); });
return result; return {
balances: debitBalance(state, balance.id),
placements,
earnedActivations: state.earnedActivations,
};
}); });
} }
@@ -1497,18 +1511,20 @@ function assignDateToBalance(
// Reject impossible mandatory moves before copying; validate complete patterns later. // Reject impossible mandatory moves before copying; validate complete patterns later.
if (!mandatoryRelocationDate(date, balance, ruleIds, context)) return null; if (!mandatoryRelocationDate(date, balance, ruleIds, context)) return null;
// Existing placements stay read-only; this assignment owns only its new placement. // Existing placements stay read-only; this assignment owns only its new placement.
const result = cloneState(state, new Map(state.placements)); const placements = new Map(state.placements);
const target = result.balances.find((item) => item.id === balanceId)!; notePlacementAdded(state.placements, placements, date);
target.remaining--; placements.set(date, {
notePlacementAdded(state.placements, result.placements, date);
result.placements.set(date, {
date, date,
balanceId, balanceId,
type: balance.type, type: balance.type,
ruleIds: new Set(ruleIds), ruleIds: new Set(ruleIds),
supporting: false, supporting: false,
}); });
return result; return {
balances: debitBalance(state, balanceId),
placements,
earnedActivations: state.earnedActivations,
};
} }
function addEarnedBalance( function addEarnedBalance(