From 0b0bb8f9a83f62868ccf33542fc9c86c4561586e Mon Sep 17 00:00:00 2001 From: Nessar Date: Sun, 18 Oct 2026 19:45:21 +0200 Subject: [PATCH] perf(planning): reuse the donor ranking across public-holiday weeks The holiday-week stage ranked every placement as a donor once per public-holiday week, although the ranking depends only on the plan. The weeks of one plan now share each placement's released objectives; each week still skips its own dates and walks the placements in the same order with the same yields. Part of #302. Co-Authored-By: Claude Opus 5.5 --- docs/benchmark/issue-302.md | 37 ++++++++++++++++++++++++++ src/app/services/leave-planner.core.ts | 22 +++++++++++---- 2 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 docs/benchmark/issue-302.md diff --git a/docs/benchmark/issue-302.md b/docs/benchmark/issue-302.md new file mode 100644 index 0000000..9159870 --- /dev/null +++ b/docs/benchmark/issue-302.md @@ -0,0 +1,37 @@ +# Issue #302: plan from the best-ranked obligation solution + +Step-by-step record for [issue #302](https://gitea.nessar.fr/nessar/vac-optimizer/issues/302). +The exact steps keep planning outcomes, stable explanations and final states +identical to the frozen engine at `a871f8f`. The +[frozen-engine differential check](README.md#frozen-engine-differential-check), +including its cancelled asynchronous comparisons, passes after every step. + +## Per-step measurements + +Each step was timed against its parent in the same session, on the development +machine (AMD Ryzen 7 7800X3D, WSL2, Bun 1.4.3), with no other test, build or +profiling workload running. Both commits' cores were bundled with the +frozen-engine build command and called through `calculatePlanningAsyncInternal` +in one Bun process, with `calculationDate: '2024-01-01'`. This is the +benchmark's in-thread asynchronous path, without the planning-calculation +service. For every scenario, each engine ran one warm-up and then one measured +calculation, alternating engines. Outcomes, explanations and final states were +hashed; every pair below matched. Only same-session pairs are comparable. + +### Reuse the donor ranking across public-holiday weeks + +For every public-holiday week, the holiday-week stage ranked every placement as +a potential donor by releasing it and evaluating the active objectives. That +ranking depends only on the plan, so the weeks of one plan now share each +placement's released objectives. Each week still skips its own dates and walks +the placements in the same order with the same yields. + +| Scenario | Before (s) | After (s) | +| --------------------------- | ---------: | --------: | +| representative year | 0.66 | 0.66 | +| typed obligations | 5.11 | 4.89 | +| chained earned entitlements | 0.02 | 0.02 | +| deliberate conflict | 0.01 | 0.01 | +| loaded year | 58.26 | 57.75 | +| two years | 2.27 | 1.82 | +| three years | 7.45 | 5.52 | diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index f91447d..04036f0 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -2208,6 +2208,7 @@ function* calendarPlacementCandidates( ruleId: string, context: SearchContext, accept: (candidate: State, addition?: boolean) => boolean, + donorObjectives = new Map(), ): PlanningSteps { const missing = targetDates.filter( (date) => !state.placements.has(date) && !context.declaredDates.has(date), @@ -2221,10 +2222,12 @@ function* calendarPlacementCandidates( yield; context.check(); if (targetDates.includes(placement.date)) continue; - rankedDonors.push({ - date: placement.date, - objectives: activeObjectiveVector(releasePlacement(state, placement.date), context), - }); + let objectives = donorObjectives.get(placement.date); + if (!objectives) { + objectives = activeObjectiveVector(releasePlacement(state, placement.date), context); + donorObjectives.set(placement.date, objectives); + } + rankedDonors.push({ date: placement.date, objectives }); } // shortcut: recursion retains the original donor ranking; rerank when a measured bundle misses a better allocation. rankedDonors.sort( @@ -2330,8 +2333,17 @@ function* holidayWeekPlacementCandidates( context: SearchContext, accept: (candidate: State, addition?: boolean) => boolean, ): PlanningSteps { + // Releasing a donor scores the same for every week of this plan. + const donorObjectives = new Map(); for (const week of context.holidayWeeks) - yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept); + yield* calendarPlacementCandidates( + state, + week.dates, + ruleId, + context, + accept, + donorObjectives, + ); } function* timeOffPeriodPlacementCandidates(