diff --git a/docs/benchmark/issue-302.md b/docs/benchmark/issue-302.md index b88873e..9a0f880 100644 --- a/docs/benchmark/issue-302.md +++ b/docs/benchmark/issue-302.md @@ -108,3 +108,26 @@ the sum. The core therefore scores them without revalidating; | loaded year | 48.12 | 44.03 | | two years | 1.04 | 0.96 | | 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. diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index 357b65e..f659554 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -696,22 +696,24 @@ function reason(code: string, message: string, ruleIds?: string[]): PlanningReas return ruleIds?.length ? { code, message, ruleIds } : { code, message }; } -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); +function copyPlacements(state: State): State['placements'] { + return new Map( + [...state.placements.entries()].map(([date, placement]) => [ + date, + { + ...placement, + ruleIds: new Set(placement.ruleIds), + }, + ]), + ); +} + +function cloneState(state: State): State { + const placements = copyPlacements(state); + derivePlacements(state.placements, placements); return { balances: state.balances.map((balance) => ({ ...balance })), - placements: clonedPlacements, + placements, earnedActivations: state.earnedActivations.map((activation) => ({ ...activation, 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 { return { calculationDate: context.request.calculationDate, @@ -1458,18 +1470,20 @@ function assignDateVariants( return compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id); }); return balances.map((balance) => { - 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, { + const placements = copyPlacements(state); + notePlacementAdded(state.placements, placements, date); + placements.set(date, { date, - balanceId: target.id, - type: target.type, + balanceId: balance.id, + type: balance.type, ruleIds: ruleId ? new Set([ruleId]) : new Set(), 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. if (!mandatoryRelocationDate(date, balance, ruleIds, context)) return null; // Existing placements stay read-only; this assignment owns only its new placement. - 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, { + const placements = new Map(state.placements); + notePlacementAdded(state.placements, placements, date); + placements.set(date, { date, balanceId, type: balance.type, ruleIds: new Set(ruleIds), supporting: false, }); - return result; + return { + balances: debitBalance(state, balanceId), + placements, + earnedActivations: state.earnedActivations, + }; } function addEarnedBalance(