Files
vac-optimizer/docs/benchmark/issue-302.md
T
nessarandClaude Opus 5.5 676ea24410 test(benchmark): tighten the relative gate to the measured speed
After #302, the development machine's protocol takes about 41 seconds
instead of 232. The budget multipliers now add about 1.5 times headroom
to the new ratios (0.1 for typed obligations, which leaves room for
runner jitter), and the hang guard drops from 600 to 240 seconds, with
a 210-second test timeout: about three times the expected run on
srvhome, which is about 1.9 times slower than nessar-desktop.

Part of #286 and #302. The gate still needs a passing CI benchmark on
two runners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:36:37 +02:00

14 KiB

Issue #302: plan from the best-ranked obligation solution

Step-by-step record for issue #302. The exact steps keep planning outcomes, stable explanations and final states identical to the frozen engine at a871f8f. The 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

Retain the best valid plan more cheaply

Retaining a better valid plan for cancellation reconciled its optimization provenance. For every placement credited by an optimization rule, that evaluated all active objectives with the placement released. Only the credited objectives are compared, so the released plan now evaluates just those, and the calendar observations of the other objectives are no longer computed. Retention also no longer computes quality metrics that nothing read. An instrumented three-year run spent 1.16 of its 1.22 retention seconds in that reconciliation; afterwards retention took 0.26 seconds.

Scenario Before (s) After (s)
representative year 0.45 0.37
typed obligations 4.76 5.03
chained earned entitlements 0.02 0.02
deliberate conflict 0.01 0.01
loaded year 55.59 53.58
two years 1.68 1.22
three years 5.29 3.85

The typed-obligations difference is run-to-run noise; its retention cost was 22 milliseconds.

Yield between asynchronous slices without a timer

The asynchronous adapter yields roughly every eight milliseconds. Each yield waited for a zero-delay timer: about 1.1 ms in Bun, and at least 4 ms in browsers once timers nest. Browsers now yield through a message-channel task. Bun runs port messages ahead of timers and incoming worker messages, so a message-channel yield there kept cancellation and the heartbeat from running until the calculation ended. Bun therefore yields with setImmediate, which costs about 0.01 ms and lets both run between slices.

Scenario Before (s) After (s)
representative year 0.37 0.31
typed obligations 4.74 4.02
chained earned entitlements 0.02 0.01
deliberate conflict 0.01 0.00
loaded year 53.80 46.90
two years 1.23 1.04
three years 3.86 3.21

The browser path was checked in headless Chromium 149 on the same machine, in a dedicated worker with a 50 ms heartbeat timer, running the bundled core before and after this step, alternating, twice each. The three years calculation took 3.92 and 3.89 seconds before and 2.70 and 2.66 seconds after; representative year took 0.51 and 0.45 seconds before and 0.30 seconds after. The heartbeat's largest gap stayed between 58 and 67 ms in every run. Cancelling three years one second after the request returned the retained plan 3.9 to 4.0 ms after the cancel message reached the worker, against 7.2 to 8.7 ms before. This is desktop evidence, not a reference-phone measurement.

Validate each optimization configuration once

Every objective evaluation revalidated its optimization configuration against the catalog. Rule normalization already validates each configuration once per calculation and keeps its own copy. The presets are constant, and the summed variant of a valid configuration is valid because every catalog row accepts the sum. The core therefore scores them without revalidating; evaluateOptimization still validates for its other callers.

Scenario Before (s) After (s)
representative year 0.35 0.30
typed obligations 4.03 3.46
chained earned entitlements 0.01 0.01
deliberate conflict 0.00 0.00
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.

Choosing the obligation solution

This is the one deliberate behavior change (ADR 0010). When obligation rules can be satisfied jointly in several ways, the planner used to run the whole preference chain on candidate solutions as previews: every solution when their enumeration completed, or the eight best-ranked when a window search stopped at its limit. It kept the solution whose preview scored best and ran the preference stages again on it. The planner now orders the solutions by the pre-preference ranking that chose those eight, the active objectives then the tie-breaks of each obligation-only plan, with enumeration order breaking equal ranks. It runs the preference stages once, on the first solution. If that run ends with earned leave available only after its final merged time-off period, the stages restart on the next solution, and a conflict is reported only when every solution fails.

Timing

Measured like the exact steps, against the last exact step, with two measured calculations per engine. All seven scenarios keep byte-identical outcomes, explanations and final states.

Scenario Before (s) After (s)
representative year 0.26 0.26
typed obligations 3.26 0.11
chained earned entitlements 0.00 0.01
deliberate conflict 0.00 0.00
loaded year 43.09 5.54
two years 0.94 0.97
three years 3.10 3.06

Comparison record

The opt-in runner compares the current engine with the calibration engine (a871f8f, which still previews) on the frozen-engine check's 5,000 seeded requests. It runs outside the functional suite and CI:

bun src/app/services/frozen-engine/compare-engines.ts small 280000 5000
Result Requests
Identical 4,970
Same objective values, other plan 6
Better 1
Worse 23
Changed outcome kind 0

Fourteen of the worse requests differ only in a final tie-break: eight on placement dates and six on balance expiry. The other nine are worse on a real objective:

Seed Worse objective Previews → ranking
280112 longest work sequence 2 → 3
280204 composed optimization 6 → 3
280686 longest work sequence 2 → 3
283162 supporting placements 0 → 1
283484 longest work sequence 4 → 5
283930 tw0 preference 2 → 1
284628 longest work sequence 8 → 16
284696 non-working-day gain 3 → 0
284824 short work-sequence deficit 0 → 2

Seed 282254 is better: its tw1 preference goes from 1 to 2. The runner prints every non-identical seed with its first differing objective; a first seed and a count of 1, such as small 284696 1, reproduce a single request.

None of these 5,000 requests reaches the earned-entitlement fallback. A separate search of seeds 200000 to 259999 with the same generator found 28 requests whose best-ranked solution earns its leave too late; all 28 plan from a later solution, and 26 of them are identical to the calibration engine's plan. Without the fallback, each would have become a conflict.

Loaded-year-sized sample

The 5,000 requests last 7 to 70 days. A second generator, generateLoadedYearRequest, builds one-year requests shaped like the loaded year scenario: four to eight CP or RTT balances of 6 to 14 days, two to four obligation windows of two or three days in distinct months, up to two preference windows, and the six presets. Their window search always stops at its solution limit, so the calibration engine previews eight solutions. Fifty of them were compared, in five parallel runs of ten seeds:

bun src/app/services/frozen-engine/compare-engines.ts loaded-year 302000 50
Result Requests
Identical 45
Worse, placement-date tie-break 1
Worse on a real objective 4
Better or changed outcome kind 0
Seed Worse objective Previews → ranking
302011 longest work sequence 14 → 19
302033 public-holiday weeks 14 → 13
302040 window-4 preference 2 → 1
302049 longest work sequence 25 → 30

On these larger plans, ranking loses a real objective more often than on the small requests: 4 in 50, against 9 in 5,000. The synchronous calibration engine took 3,924 seconds for the 50 requests, about 78 seconds each, and the current engine 289 seconds, about 5.8 seconds each.

Benchmark protocol

The unchanged benchmark protocol ran on the development machine before the first exact step (b356117, from a separate worktree) and after the frozen engine rebuild, one after the other in the same session, with no other workload:

timeout 600s bun test src/app/benchmark --timeout=600000
Scenario Before (s) After (s) Target (s)
representative year 0.45 0.24 3
typed obligations 4.70 0.10 3
chained earned entitlements 0.03 0.01 3
deliberate conflict 0.01 0.00 3
loaded year 57.25 5.51 10
two years 2.19 0.93 5
three years 7.03 3.11 5
calibration (two years) 3.40 3.30
whole test run 232.37 40.78

Each figure is the mean of the two measured calculations. All seven scenarios keep their outcomes, placement counts, completed stages and determinism, and every scenario now meets its target.

Gate

For #286, the multipliers now add about 1.5 times headroom to these ratios: 0.15 for the representative year, 0.1 for typed obligations, 0.05 for the two tiny scenarios, 2.5 for the loaded year, 0.5 for two years and 1.5 for three years. srvhome is about 1.9 times slower than nessar-desktop, so it should need about 80 seconds for the protocol. The CI timeout is now 240 seconds and the test timeout 210 seconds, about three times that. The CI benchmark has not yet run on the Gitea runners with these values.