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(