Compare commits

..
Author SHA1 Message Date
nessarandClaude Opus 5.5 31cb4a499c fix(planning): recalibrate the duration estimate to the faster engine
CI / policy (push) Successful in 20s
CI / validate (push) Successful in 36s
CI / build (push) Successful in 23s
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 25s
CI / benchmark (push) Successful in 1m29s
CI / security (push) Successful in 1m10s
CI / build (pull_request) Successful in 23s
CI / test (push) Successful in 2m16s
CI / benchmark (pull_request) Successful in 1m27s
CI / security (pull_request) Successful in 1m0s
CI / images (push) Successful in 59s
CI / required (push) Successful in 1s
CI / test (pull_request) Successful in 2m6s
CI / images (pull_request) Successful in 53s
CI / required (pull_request) Successful in 1s
Rendered waits after #302, in throttled headless Chromium, were well
below the shown ranges: two years waited 1.9 s against 3-24 s, three
years 4.5 s against 11-68 s, and the loaded year 4.0 s against 4-26 s.
The prior keeps its structure with refitted, rounded constants,
400 + 250 x (D/365.25)^0.5 x (P/30)^1.5 x R, which stays within about
1.2 times of all six measured configurations. Five years with 30 days
per year now shows 6-37 s instead of 44-265 s. Local refinement is
unchanged.

The 4x CPU throttle slows only the main thread, not the planning
worker, so phones can still wait longer than these desktop samples.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:31:28 +02:00
nessarandClaude Opus 5.5 75320da685 docs(benchmark): compare engines by hand before each frozen-engine rebuild
A standing "no worse than the previous engine" check is not added to
CI: the differential check already fails on unintended changes, and a
fixed acceptance threshold would need renegotiating with every
deliberate change. Instead, the rebuild procedure now runs the
comparison while the frozen engine is still the previous one, and
records it with the change. The runner compares against the frozen
engine by default and takes `calibration` to reproduce the #302 record.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:12:50 +02:00
nessarandClaude Opus 5.5 fe705fe618 fix(planning): keep counterfactual moves off the shared final plan
CI / policy (push) Successful in 28s
CI / validate (push) Successful in 46s
CI / build (push) Successful in 25s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 26s
CI / benchmark (push) Successful in 1m28s
CI / security (push) Successful in 1m11s
CI / build (pull_request) Successful in 23s
CI / test (push) Successful in 2m27s
CI / benchmark (pull_request) Successful in 1m33s
CI / security (pull_request) Successful in 1m0s
CI / images (push) Successful in 1m0s
CI / required (push) Successful in 1s
CI / test (pull_request) Successful in 2m6s
CI / images (pull_request) Successful in 54s
CI / required (pull_request) Successful in 1s
Since assigning a date shares unchanged activations with the parent
state, counterfactual analysis edited an activation it shared with the
final plan, so a later alternative date could inherit an earlier one's
dates and be judged eligible. It now replaces the moved activation.
Every counterfactual over seeds 280000-289999 matches the engine before
the sharing step again.

When every one of several obligation solutions fails earned-entitlement
chronology, the planner again reports the obligation conflict, as when
every preview failed. No seeded request reaches that path, so the
frozen engine is unchanged.

Also marks the ranking's known limit as a shortcut and fixes stale
documentation found in review.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:49:55 +02:00
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
nessarandClaude Opus 5.5 6c9ff4ae34 test(planning): rebuild the frozen engine from the obligation ranking
The frozen engine of the differential check is rebuilt from 8168137,
which plans from the best-ranked obligation solution, with the
documented build command. The check passes again, including its
cancelled asynchronous comparisons and generator-coverage assertions.
The calibration engine stays at a871f8f.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:18:40 +02:00
nessarandClaude Opus 5.5 8168137cd5 perf(planning): plan from the best-ranked obligation solution
With several obligation solutions, the planner ran the whole preference
chain on candidate solutions as previews: every solution after a
complete enumeration, or the eight best-ranked after a truncated window
search. It now orders all solutions by the existing pre-preference
ranking (active objectives, then tie-breaks, of each obligation-only
plan; equal ranks keep enumeration order) and runs the preference
stages once, on the first. If that run ends with earned leave available
only after its final merged time-off period, the stages restart on the
next solution, so a request that planned before never becomes a
conflict.

The loaded year drops from 43 to 5.5 seconds and typed obligations from
3.3 to 0.1 seconds, with byte-identical benchmark plans. Over the 5,000
seeded requests, 23 plans become worse (9 on a real objective) and 1
better; over 50 loaded-year-sized requests, 4 become worse on a real
objective. ADR 0010 records the decision; docs/benchmark/issue-302.md
records the comparison and the opt-in runner that reproduces it.

The frozen-engine check fails until the frozen engine is rebuilt from
this commit in the next one.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:18:06 +02:00
nessarandClaude Opus 5.5 aa6a0c6f1b 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>
2026-10-18 20:08:12 +02:00
nessarandClaude Opus 5.5 9d9404c3db perf(planning): validate each optimization configuration once
Every objective evaluation revalidated its optimization configuration.
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 stays valid, so the core now
scores them without revalidating. evaluateOptimization still validates
for other callers.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:03:22 +02:00
nessarandClaude Opus 5.5 b7ec411c92 perf(planning): yield between asynchronous slices without a timer
Each slice of the asynchronous adapter 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, which starved
cancellation and heartbeats, so it yields with setImmediate. Both still
let queued cancellation and heartbeat tasks run between slices.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:58:40 +02:00
nessarandClaude Opus 5.5 2fcba758c8 perf(planning): retain the best valid plan more cheaply
Retaining a better valid plan reconciled its optimization provenance,
which evaluated every active objective once per credited placement with
that placement released. Only the credited objectives are compared, so
the released plan now evaluates just those and skips observing the
others. Retention also stops computing quality metrics that nothing
read. The retained plan and what Cancel returns are unchanged.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:50:58 +02:00
nessarandClaude Opus 5.5 0b0bb8f9a8 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 <noreply@anthropic.com>
2026-10-18 19:45:21 +02:00
nessarandClaude Opus 5.5 b356117263 test(benchmark): keep the a871f8f engine as a fixed calibration engine
The differential check's frozen bundle will be rebuilt for #302; the
relative gate keeps timing the unchanged a871f8f bundle so its ratios
stay comparable with the recorded history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:14:28 +02:00
nessar ba05fe88b3 chore(deps): update node.js to v24.22.0
CI / policy (push) Successful in 10s
CI / build (push) Successful in 34s
CI / validate (push) Successful in 2m4s
CI / security (push) Successful in 1m33s
CI / benchmark (push) Successful in 4m34s
CI / test (push) Successful in 4m49s
CI / images (push) Successful in 2m19s
CI / required (push) Successful in 1s
2026-10-18 13:32:24 +02:00
28 changed files with 4904 additions and 672 deletions

No files matched your search

+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun scripts/generate-langs.js
- name: Benchmark protocol with relative gate
run: timeout 600s bun test src/app/benchmark --timeout=600000
run: timeout 240s bun test src/app/benchmark --timeout=240000
build:
name: build
+3
View File
@@ -104,6 +104,9 @@ An optimization rule that minimizes unplaced at-risk leave.
**Obligation rule**:
A planning rule that every valid plan must satisfy. Obligation rules are resolved jointly; their order only breaks ties between valid plans.
**Obligation solution**:
One way to satisfy every obligation rule jointly, before any preference rule applies. When there are several, the planner ranks them on their obligation-only plans by the planning objective order and its final tie-breaks, and completes the planning objective order from the best-ranked one only.
**Preference rule**:
A planning rule that guides placement but may remain unsatisfied to preserve validity or satisfy higher-priority preferences.
+3 -3
View File
@@ -216,7 +216,7 @@ Open <http://localhost:4200>. The development server reloads when source files c
| `bun run watch` | Rebuild the development bundle when source files change. |
| `bun run test` | Run the unit, integration and benchmark suites once. |
| `bun test --isolate --path-ignore-patterns='**/benchmark/**'` | Run the functional suite only. |
| `bun test src/app/benchmark --timeout=600000` | Run the planning benchmark (no coverage). |
| `bun test src/app/benchmark --timeout=240000` | Run the planning benchmark (no coverage). |
| `bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**' && bun scripts/coverage-reports.mjs` | Functional coverage: text, `lcov.info`, HTML and Cobertura in `coverage/`. Line coverage only; frozen engine excluded. |
| `bun run lint` | Run ESLint (`bun run lint:fix` applies its fixes). |
| `bun run format:check` | Check Prettier formatting of the application, the CI scripts and the workflow (`bun run format` writes it). |
@@ -232,7 +232,7 @@ Angular compiles with TypeScript 6. `bun check` is Bun's TypeScript 7 checker fo
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, a loaded year, and two- and three-year horizons with dated annual balances. It reports preparation time, planning latency, planning-work time, total wall time through explanation delivery, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, and determinism.
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or individual multi-year duration ceiling is established. CI runs the complete seven-scenario protocol with `bun test` and requires each scenario to stay within its budget relative to a frozen-engine calibration, with a 600-second hang guard. Bun measurements do not establish browser or reference-phone speedups.
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or individual multi-year duration ceiling is established. CI runs the complete seven-scenario protocol with `bun test` and requires each scenario to stay within its budget relative to a frozen-engine calibration, with a 240-second hang guard. Bun measurements do not establish browser or reference-phone speedups.
## Docker
@@ -253,7 +253,7 @@ The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edi
Jobs that run project commands install Bun 1.4.3 with `scripts/install-bun.sh` (a checksum-verified download, no npm) and run their steps with a `PATH` that holds Bun but no Node. The job containers keep Node only for Gitea's checkout and artifact actions, which are JavaScript actions the runner executes itself; see the [activation guide](docs/gitea-actions.md#runners-and-permissions).
Functional tests run with `bun test --isolate`; coverage is line-level only (Bun reports no branch or function data). The separate required benchmark job runs without coverage in a container limited to 3 GiB (JavaScriptCore has no heap-size flag, so this replaces the former 2 GiB Node heap cap) and keeps its 600-second hang guard, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restores this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). Benchmark success is required for image publication and the aggregate `required` job. The narrow local timing margin still needs confirmation on the CI runner.
Functional tests run with `bun test --isolate`; coverage is line-level only (Bun reports no branch or function data). The separate required benchmark job runs without coverage in a container limited to 3 GiB (JavaScriptCore has no heap-size flag, so this replaces the former 2 GiB Node heap cap) and keeps its 240-second hang guard, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restored this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). After [#302](docs/benchmark/issue-302.md) the local protocol takes about 41 seconds. Benchmark success is required for image publication and the aggregate `required` job. The lowered hang guard and tightened multipliers still need confirmation on the CI runners.
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
@@ -2,6 +2,8 @@
The ten-second automatic deadline is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), implemented by issue #59. Technical callers can still opt into an explicit planning-work budget; the user flow has no automatic cutoff.
The five-second target is revisited by [ADR 0010](0010-rank-obligation-solutions-before-preferences.md), which sets local benchmark targets of ten seconds for the loaded year, five seconds for multi-year horizons and three seconds otherwise.
The fixed ordering between user preferences and configurable optimization objectives, including the unconditional priority for at-risk loss prevention, is superseded by [ADR 0008](0008-user-controlled-planning-objectives.md); implementation of that decision is pending.
VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, complete public-holiday weeks, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001.
@@ -0,0 +1,9 @@
# Rank Obligation Solutions Before Preferences
Accepted for [issue #302](https://gitea.nessar.fr/nessar/vac-optimizer/issues/302), after the analysis in [issue #288](https://gitea.nessar.fr/nessar/vac-optimizer/issues/288).
A search change may change planning outcomes when it is deliberate, isolated in one reviewed commit and recorded. The frozen engine of the [frozen-engine differential check](../benchmark/README.md#frozen-engine-differential-check) is rebuilt from that commit, in its own commit, and the benchmark documentation records a reproducible before/after comparison over the check's seeded requests, including every request that became worse on a real objective. Exact optimizations still keep every outcome, stable explanation and final-state snapshot identical to the frozen engine.
When obligation rules can be satisfied jointly in several ways, the planner orders the obligation solutions by its pre-preference ranking: the active objectives, then the tie-breaks, evaluated on the obligation-only plan. Equal ranks keep enumeration order. The planner runs the preference stages once, on the first solution, instead of previewing the preference stages on every candidate solution. This removes a cost that grew with the number of solutions, and trades away the cases where a lower-ranked solution ends better after the preferences: over the 5,000 seeded requests, 23 plans became worse, 9 of them on a real objective, and 1 became better; over 50 loaded-year-sized requests, 4 became worse on a real objective ([comparison record](../benchmark/issue-302.md#comparison-record)). If the run on the chosen solution ends in an earned-entitlement chronology conflict, the planner tries the next solution in that order, and reports a conflict only when every solution fails.
The Bun benchmark targets, measured with the benchmark protocol on the development machine, are 10 seconds for the loaded year, 5 seconds for multi-year horizons and 3 seconds for every other scenario. They are development targets, not reference-phone limits. This revisits the five-second target of [ADR 0003](0003-constraint-first-lexicographic-planning.md); [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md) already removed its automatic cutoff and sets no validated limit for multi-year horizons.
+123 -24
View File
@@ -6,7 +6,8 @@
unchanged Node acceptance protocol on the documented development laptop:
**298.6 seconds**, including one warm-up and two measured repetitions of all
seven scenarios, without coverage, with one worker and a 2 GiB Node heap.
The full protocol keeps a 600-second timeout only as a hang guard (see
After [#302](issue-302.md#benchmark-protocol), the Bun protocol takes about 41
seconds, and its timeout is a 240-second hang guard (see
[Relative CI gate](#relative-ci-gate)). All expected outcomes,
placement counts, final stages and determinism checks pass. The fixture inputs
are unchanged; the test now prints its existing report.
@@ -18,13 +19,15 @@ The relative gate replaces the 300-second acceptance protocol named in
policy. This is an explicit policy change from #279/#285: runners differ in
speed, so a fixed wall-clock limit was flaky.
Before the protocol, in the same process, the spec times the frozen engine
(`src/app/services/frozen-engine/leave-planner.core.frozen.mjs`, the engine at
a871f8f) on the `two years` scenario, best of three, and prints the result.
Before the protocol, in the same process, the spec times the calibration engine
(`src/app/services/frozen-engine/leave-planner.core.calibration.mjs`, the engine at
a871f8f) on the `two years` scenario, best of three, and prints the result. This
bundle never changes, so ratios stay comparable with the recorded history; the
differential check's frozen bundle can be rebuilt independently.
Each scenario's budget is its multiplier times that calibration; the mean
`planningMs` of its two measured repetitions must not exceed it. A scenario
without a multiplier fails too. `#286` tightens the multipliers once the speed
target is met.
without a multiplier fails too. #286 tightened the multipliers once #302 met
the speed targets.
The job log shows a Markdown table with one row per scenario, printed as soon
as that scenario finishes. Each row gives both measured planning times, their
@@ -34,18 +37,22 @@ tab-separated report follows the table.
| Scenario | Mean planning (s) | Ratio to calibration | Multiplier |
| --------------------------- | ----------------- | -------------------- | ---------- |
| representative year | 0.40 | 0.15 | 0.25 |
| typed obligations | 5.02 | 1.83 | 3 |
| chained earned entitlements | 0.025 | 0.01 | 0.05 |
| deliberate conflict | 0.013 | 0.005 | 0.05 |
| loaded year | 44.3 | 16.2 | 25 |
| two years | 1.84 | 0.67 | 1 |
| three years | 5.94 | 2.17 | 3.5 |
| representative year | 0.24 | 0.07 | 0.15 |
| typed obligations | 0.10 | 0.03 | 0.1 |
| chained earned entitlements | 0.01 | 0.00 | 0.05 |
| deliberate conflict | 0.00 | 0.00 | 0.05 |
| loaded year | 5.51 | 1.67 | 2.5 |
| two years | 0.93 | 0.28 | 0.5 |
| three years | 3.11 | 0.94 | 1.5 |
Ratios come from the development machine (calibration about 2.74 s); the
multipliers add roughly 1.5x headroom, rounded up, with a floor of 0.05 for the
two tiny scenarios. A failure lists, per scenario, the measured mean, the
budget, the multiplier and the calibration in milliseconds.
Ratios come from the development machine's Bun protocol after #302
(calibration 3.30 s, [record](issue-302.md#benchmark-protocol)); the multipliers
add roughly 1.5x headroom, rounded up, with a floor of 0.05 for the two tiny
scenarios. Typed obligations gets 0.1: its 0.1-second run would leave a 0.05
budget only about 80 ms of headroom for runner jitter. Before #302, the
multipliers were 0.25, 3, 0.05, 0.05, 25, 1 and 3.5. A failure lists, per
scenario, the measured mean, the budget, the multiplier and the calibration in
milliseconds.
The first CI runs of PR #287 (commit `63b50c4`) give consistent ratios on two
runners:
@@ -64,12 +71,14 @@ runners:
srvhome is about 1.9 times slower than nessar-desktop, so its whole protocol
needs roughly 400 seconds. The original 300-second `timeout` killed it during
the loaded year, while every completed scenario was within budget. The CI
`timeout` is therefore 600 seconds and the test timeout 570 seconds. They are
only hang guards and do not judge speed; #286 lowers them once the speed
target is met.
`timeout` was therefore 600 seconds and the test timeout 570 seconds. After
#302 the whole local protocol takes about 41 seconds, so srvhome should need
about 80. #286 lowered the CI `timeout` to 240 seconds and the test timeout to
210 seconds, about three times that. They are only hang guards and do not
judge speed.
The protocol now runs on Bun's test runner, outside coverage:
`timeout 600s bun test src/app/benchmark --timeout=600000`. JavaScriptCore has
`timeout 240s bun test src/app/benchmark --timeout=240000`. JavaScriptCore has
no equivalent of Node's `--max-old-space-size`, so the CI benchmark job enforces
memory with a 3 GiB container limit instead of the former 2 GiB heap cap.
Scenarios, calibration, warm-up, repetitions, determinism checks and
@@ -118,8 +127,11 @@ trace counters and timings are historical; current evidence is in
`src/app/services/frozen-engine.spec.ts` runs 5,000 seeded random planning
requests (seeds 280000 to 284999, one test per 200 seeds) through the current
engine and through a frozen copy of the engine at `a871f8f`, develop after
PR #278. For each request, the planning outcome, stable plan explanation,
engine and through a frozen copy of the engine. The copy was rebuilt for #302
from `8168137`, which plans from the best-ranked obligation solution
([comparison record](issue-302.md#comparison-record)). Until then it was the
engine at `a871f8f`, develop after PR #278, which remains the calibration
engine. For each request, the planning outcome, stable plan explanation,
explanation error and final-state snapshot must be strictly deep-equal,
including `PlanningConflict` and `InvalidInput` outcomes. Timings are not
compared.
@@ -136,15 +148,32 @@ if an outcome kind, rule type, year-crossing horizon or public-holiday horizon
stops appearing. Requests stay small because candidate search grows quickly
with horizon length and balances; the slowest case takes about one second.
The generator is `src/app/services/frozen-engine/random-planning-requests.ts`.
`compare-engines.ts` beside it is an opt-in runner, outside the functional suite
and CI, that classifies how the current engine's plans differ from the frozen
engine's, or with `calibration` from the calibration engine's, over the same
seeds; [the #302 record](issue-302.md#comparison-record) shows its use.
The fixture is `src/app/services/frozen-engine/leave-planner.core.frozen.mjs`,
a generated bundle with `date-holidays` left external, and its hand-written
`leave-planner.core.frozen.d.mts`. Rebuild it only for an intended behavior
change, in its own reviewed commit, from the commit that introduces the change:
change, in its own reviewed commit, from the commit that introduces the change.
Before rebuilding, while the frozen engine is still the previous one, run the
comparison and record its counts and every request that became worse on a real
objective with the change, as [the #302 record](issue-302.md#comparison-record)
does:
```sh
bun src/app/services/frozen-engine/compare-engines.ts small 280000 5000
bun build src/app/services/leave-planner.core.ts --target=node --external date-holidays --outfile src/app/services/frozen-engine/leave-planner.core.frozen.mjs
```
The comparison is deliberately not a CI check. The differential check already
fails on any unintended change; the comparison only describes an intended one,
and a fixed acceptance threshold would have to be renegotiated with every
rebuild. The loaded-year-sized sample (`loaded-year 302000 50`) takes about an
hour and is worth running when a change targets large plans.
A failure prints the seed and the full request as JSON. To run only that seed:
```sh
@@ -209,6 +238,76 @@ outcome, and delivery of its stable explanation. Confirm
that editing an input remains possible during active worker CPU work. These
measurements describe that device and workload; they impose no latency limit.
## Indicative wait recalibration — 2026-10-18
[Issue #302](issue-302.md) made planning much faster, so the rough prior
behind the range beside **Calculate** (see
[planning-duration.md](../planning-duration.md)) overstated every request with
many obligation solutions or a multi-year horizon. These measurements refit it.
The NEW builds are the production build of `75320da` (the #302 engine); the OLD
column is the production build of `b356117`, the last commit before the #302
engine changes. Both ran in headless Chromium 149.0.7827.55 on an AMD Ryzen 7
7800X3D desktop under Debian 13 on WSL2. Emulation used 412×915 pixels, scale
2.625, touch, reduced motion and 4× CPU throttling; external font downloads
were blocked. **This is desktop emulation, not a phone measurement.**
`Emulation.setCPUThrottlingRate` slows the page's main thread but does not
throttle the dedicated planning worker, so the search ran at desktop speed.
A phone, whose worker is also slower, can wait longer than any figure here.
Every run used a fresh browser. The application's own initial calculation (one
saved day) finished first. The run then enabled any custom rules with the real
toggles and set the planning dates through the real date controls; the saved
balances, rules and dates were checked, and the calendar stayed unchanged until
one Calculate click. The wait is the time from that click to the displayed
final placements with the loading overlay gone, the endpoint described in
[planning-duration.md](../planning-duration.md). Every run returned a `Plan`
with the placement count shown. NEW has three runs per configuration; OLD has
two, and was not run for configurations 4 and 5. Configurations 1 to 3 give each
year 20 CP and 10 RTT days available January 1 and expiring December 31; every
configuration keeps the six default optimization presets, and 4 and 5 are the
benchmark's "loaded year" and "typed obligations" scenarios from
`benchmarkScenarios()`.
| # | Configuration | Placements | NEW wait, ms mean [min–max] | OLD wait, ms mean [min–max] | Range shown, old constants | Range shown, new constants |
| --- | ----------------------------------------------------- | ---------: | --------------------------- | --------------------------- | -------------------------- | -------------------------- |
| 1 | One year 2024, 20 CP + 10 RTT | 30 | 730 [716–752] | 1,014 [990–1,037] | 1–5 s | 1–3 s |
| 2 | Two years 2024–2025, 20 CP + 10 RTT per year | 60 | 1,909 [1,880–1,943] | 3,137 [3,121–3,153] | 3–24 s | 1–7 s |
| 3 | Three years 2024–2026, 20 CP + 10 RTT per year | 90 | 4,477 [4,450–4,498] | 8,250 [8,241–8,258] | 11–68 s | 2–15 s |
| 4 | Loaded year (6×12 days, 3 obligation + 2 preferences) | 72 | 4,024 [3,981–4,076] | not run | 4–26 s | 1–12 s |
| 5 | Typed obligations | 10 | 640 [599–687] | not run | 1–2 s | 1–2 s |
| 6 | One year 2024, 5 CP only | 5 | 465 [457–472] | 518 [518–518] | 1–2 s | 1–2 s |
The old ranges are the application's no-sample ranges before the change, and
the new ones were confirmed in the rebuilt application for the same six saved
configurations. The earlier 2026-10-05 references (1.74, 5.08 and about 11
seconds) came from a different build, before many engine speedups, which is why
the OLD column is lower than them.
The refit keeps the model's structure and rounds its constants:
`400 + 250 × (D / 365.25)^0.5 × (max(1, P) / 30)^1.5 × R`. With `D` the horizon
in days, `P` the placement load and `R` the rule weight, it gives:
| # | `P` | `R` | `D` | Old estimate, ms | New estimate, ms | Measured, ms | New / measured |
| --- | --- | --- | ----- | ---------------: | ---------------: | -----------: | -------------: |
| 1 | 30 | 1.9 | 366 | 1,443 | 875 | 730 | 1.20 |
| 2 | 60 | 1.9 | 731 | 7,714 | 2,301 | 1,909 | 1.21 |
| 3 | 90 | 1.9 | 1,096 | 22,444 | 4,675 | 4,477 | 1.04 |
| 4 | 72 | 3.7 | 366 | 8,574 | 3,843 | 4,024 | 0.95 |
| 5 | 10 | 2.9 | 366 | 636 | 540 | 640 | 0.84 |
| 6 | 5 | 1.9 | 366 | 378 | 432 | 465 | 0.93 |
Every measured mean lies well inside the unrounded range from half to three
times the estimate. Configurations 1, 5 and 6 finished below the displayed
minimum of 1 second, which the whole-second display cannot go under. Horizon
length and placements grew together in configurations 1 to 3, so the data does
not separate the 0.5 horizon power from the 1.5 placement power; the sum
reproduces the roughly squared growth with years above a fixed overhead. The
same prior estimates five years with 30 days per year (`P` = 150, `D` = 1,826)
at about 12 seconds, displayed as 6–37 seconds; the old constants gave
44–265 seconds. Horizons beyond three years remain unvalidated extrapolation.
These samples set a rough prior and impose no duration ceiling.
## Historical measurements before trace removal
The following records remain as evidence for their dated builds. Trace-related behavior and metrics below are superseded by issue #76.
+293
View File
@@ -0,0 +1,293 @@
# 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.
That check does not compare counterfactual analyses. Code review found that the
last exact step, which shares unchanged activations between states, let
counterfactual analysis edit an activation it shared with the final plan, so a
later alternative date could inherit an earlier one's dates. A follow-up commit
replaces that activation instead of editing it. Afterwards, every
counterfactual of every plan placement for seeds 280000 to 289999, 8,750
analyses, matched the engine before that step; the same comparison found 4
differences with the step alone.
## 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](../adr/0010-rank-obligation-solutions-before-preferences.md)).
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. With several solutions,
that is the obligation conflict the previews reported when every preview
failed.
### 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:
```sh
bun src/app/services/frozen-engine/compare-engines.ts small 280000 5000 calibration
```
| 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 calibration`, reproduce a single request.
Without `calibration`, the runner compares against the frozen engine, which is
rebuilt from this change and therefore matches it.
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:
```sh
bun src/app/services/frozen-engine/compare-engines.ts loaded-year 302000 50 calibration
```
| 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:
```sh
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](https://gitea.nessar.fr/nessar/vac-optimizer/issues/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.
+2 -2
View File
@@ -21,7 +21,7 @@ configuration; the Compose files remain manual deployment options.
| `policy` | Tests the policy and publisher with Bun, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `validate` | Runs ESLint, Prettier (application, `scripts/ci-*.mjs` and workflow files) and `bun check` type checks of application and test code, all on Bun with the frozen lockfile. |
| `test` | Runs functional coverage with `bun test --isolate` (line coverage only), converts it to HTML and Cobertura, and uploads coverage for seven days even on failure. |
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage in a container limited to 3 GiB. It first times the frozen engine on `two years` (best of three) and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 600-second timeout is only a hang guard. |
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage in a container limited to 3 GiB. It first times the frozen engine on `two years` (best of three) and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 240-second timeout is only a hang guard. |
| `build` | Builds the client-only Angular production bundle (the only check of Angular templates; `bun check` does not replace it) and the Bun static-server bundle, and retains `dist` for seven days. |
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. The scanners are not project JavaScript, so this job installs no Bun. |
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. Runs only for pushes and same-repository PRs. |
@@ -32,7 +32,7 @@ publication (`images.needs` includes `benchmark`) and the `required` aggregate.
The relative gate absorbs a slower runner and load that lasts through the whole
job, because calibration runs in the same job. It does not absorb load that
starts or stops during the protocol, such as an overlapping push and
pull_request run of the same commit; the 600-second hang guard still bounds
pull_request run of the same commit; the 240-second hang guard still bounds
those runs.
Push and PR runs are separate so PR title and rebase checks run on the PR event.
+2 -2
View File
@@ -6,7 +6,7 @@ Issue [#58](https://gitea.nessar.fr/nessar/vac-optimizer/issues/58) implements t
## Stopping CPU work
The core exposes resumable generator steps. `calculatePlanningAsyncInternal` drives those same steps in roughly eight-millisecond slices and yields to the worker event loop at phase boundaries and between preparation years. A `cancel` message sets the active run's cancellation flag; the next resume throws the cancellation signal into the suspended calculation. Recursive obligation and preference searches, quality passes, and calendar preparation unwind through their existing cleanup blocks. An individual synchronous operation, such as the holiday library's work for one year, finishes before the next pause.
The core exposes resumable generator steps. `calculatePlanningAsyncInternal` drives those same steps in roughly eight-millisecond slices and yields to the worker event loop at phase boundaries and between preparation years. Browsers yield through a message-channel task, because nested timers are clamped to at least 4 ms; Bun yields with `setImmediate`, because it runs port messages ahead of timers and incoming worker messages. A `cancel` message sets the active run's cancellation flag; the next resume throws the cancellation signal into the suspended calculation. Recursive obligation and preference searches, quality passes, and calendar preparation unwind through their existing cleanup blocks. An individual synchronous operation, such as the holiday library's work for one year, finishes before the next pause.
The synchronous `calculatePlanningInternal` adapter drains the same steps without pauses for existing direct core callers. The public non-browser calculation context uses the asynchronous driver so its cancellation method also works.
@@ -16,7 +16,7 @@ User cancellation is distinct from replacement by another calculation (`stale-ca
The asynchronous calculation retains a copied incumbent as soon as the obligation search finds a jointly valid solution, before that entire stage has completed. It also considers accepted placements and every feasible evaluated quality candidate, including candidates rejected by a stage's narrower comparator. Each candidate is reconciled for earned entitlement availability and checked against all obligation rules before retention. The existing full lexicographic comparator chooses the best available incumbent.
Retention reuses quality metrics already computed for an evaluated candidate. An intermediate state with more unplaced at-risk leave, or worse preferences at equal unplaced leave, is rejected using that objective prefix alone. This avoids rescoring dominated states during large obligation previews without skipping a candidate that could improve the retained plan.
Retention reuses the objective vector already computed for an evaluated candidate. An intermediate state with more unplaced at-risk leave, or worse preferences at equal unplaced leave, is rejected using that objective prefix alone. This avoids rescoring dominated states without skipping a candidate that could improve the retained plan.
Preference occurrence dates and patterns are calculated once for the run's immutable calendar and rules. Each comparison still checks the candidate's actual placements against those patterns; candidate scores are not cached.
+25 -14
View File
@@ -13,19 +13,24 @@ horizon dates hide the estimate and retain the existing disabled Calculate
control. Other malformed inputs keep a rough fallback estimate; the planner
still performs its own validation.
The initial model is a deliberately rough engineering prior grounded in the
[browser reference measurements](benchmark/README.md): the 2026-10-03 annual
30-day scenario returned its outcome in about 0.6 seconds, and the 2026-10-05
two- and three-year calendar runs with 30 days per year took 5.08 and 10.86
seconds. These are throttled desktop measurements, not phone measurements or a
validated prediction model. The annual outcome timing does not include final
calendar rendering. The new rendered-calendar measurements below supplement
those references.
The initial model is a deliberately rough engineering prior fitted to the
rendered waits recorded in the
[2026-10-18 rendered-wait recalibration](benchmark/README.md): six
configurations, from a five-day request to a three-year horizon with 30 days
per year, each timed from the Calculate click to the rendered final calendar.
They were taken after the engine speedups of
[issue #302](https://gitea.nessar.fr/nessar/vac-optimizer/issues/302), which
made the earlier 2026-10-03 and 2026-10-05 references obsolete as grounding.
These are desktop emulation measurements: 4× CPU throttling
slows the main thread but not the dedicated planning worker, so they are not
phone measurements, and a slower device can wait longer than the prior says.
The prior is not a validated prediction model; its rounded constants stay
within about 1.2× of every measured mean.
For a half-open horizon of `D` calendar days, the model uses:
```text
initial_ms = 300 + 600 × (D / 365.25)^1.2 × (max(1, P) / 30)^1.5 × R
initial_ms = 400 + 250 × (D / 365.25)^0.5 × (max(1, P) / 30)^1.5 × R
```
`P` approximates placement load. Each non-negative finite balance contributes
@@ -42,11 +47,17 @@ solving constraints or counting holiday occurrences in the form. Typed
obligations, date distribution, interacting rules, earned entitlements, device
load and browser differences can all change the actual wait substantially.
The powers and weights are implementation choices, not complexity guarantees
or fitted accuracy claims. With 30 available days per year, the model's search
term grows as the number of years to the power 2.7, rather than assuming a
linear increase. Horizons beyond the references are unvalidated extrapolation.
The estimate performs one pass over balances and rules and runs no planner.
The constants are fitted to a handful of runs, not derived. The fixed 400 ms
approximates request scheduling, worker startup and rendering (a one-year
request for 5 CP waited 465 ms). The one-to-three-year runs grew roughly as the
number of years squared above such an overhead (730, 1,909 and 4,477 ms).
Horizon length and placements grew together in them, so the 0.5 horizon power
is a fit, not a finding about horizon length alone. Five years with 30
available days per year (20 CP plus 10 RTT and the six default optimization
presets) now estimates about 12 seconds, displayed as 6–37 seconds; the
previous constants gave 44–265 seconds. Horizons beyond three years are
unvalidated extrapolation. The estimate performs one pass over balances and
rules and runs no planner.
## Local refinement and precision
+14 -13
View File
@@ -140,12 +140,15 @@ Moves that preserve every active objective can be pruned when their final tie-br
cannot improve. Per-iteration vectors are reused; preset statistics are materialized
only for selected incumbents and published plans.
For large non-earned window obligation searches above the existing exact-branch
threshold, the joint feasibility search retains its existing limit. Feasible
solutions are ranked by actual active objectives and final ties; the first eight
receive full optimization previews. Other feasible solutions are recorded as
`not-examined`. Small searches and earned-entitlement chains keep all previews.
This deterministic sampling has no elapsed-time cutoff or user-facing setting.
For large non-earned obligation searches above the existing exact-branch
threshold, the joint feasibility search retains its existing limit. When several
obligation solutions are found, they are ranked by the actual active objectives
and final ties of their obligation-only plans, with enumeration order breaking
equal ranks. The preference stages run once, on the best-ranked solution; no
solution receives a preference preview ([ADR 0010](adr/0010-rank-obligation-solutions-before-preferences.md)).
If that run ends with earned leave available only after its final merged
time-off period, the stages restart on the next solution in rank order. This
deterministic ranking has no elapsed-time cutoff or user-facing setting.
A deferred optimization addition is supporting only when it provides one of the
calendar benefits in the domain definition, rather than merely extending a period.
@@ -186,10 +189,10 @@ These are deterministic search limits, rather than claims of global optimality:
declared-adjacent, weekly and calendar-window destinations. Other destinations
retain `not-examined` trace records.
- Non-earned obligation searches whose estimated branch count exceeds 10,000 retain
at most 4,096 feasible solutions. Large window searches rank them by the actual
active vector and final ties, then fully preview the first eight; the other
feasible solutions are marked `not-examined`. Earned chains and small searches
retain all previews.
at most 4,096 feasible solutions. Every search ranks its solutions by the actual
active vector and final ties of the obligation-only plans, and the preference
stages run on the best-ranked solution only. A lower-ranked solution that would
end better after the preferences is not found.
- Calendar bundles rank original donors once by the actual active vector of the
source plan with each donor released, then retain that order during recursive
allocation. They keep the first prefix-valid allocation and collapse equivalent
@@ -209,9 +212,7 @@ These are deterministic search limits, rather than claims of global optimality:
plateau has no specialized bundle generator; the supplied sum/minimize loss
preset does consume both.
A completely enumerated single obligation solution has no alternative to rank, so
its discarded full preference preview is skipped; the real ordered execution still
runs in full. Earlier rules reuse their completed evaluation only when the complete
Earlier rules reuse their completed evaluation only when the complete
ordered incoming state matches their last output, including balance quantities and
validity, earned activations, placement insertion order, supporting status and rule
provenance. The trace records that unchanged state as `not-examined` with the earlier
@@ -1,5 +1,5 @@
import { vi } from '../../testing/vitest-compat';
import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-planner.core.frozen.mjs';
import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-planner.core.calibration.mjs';
import { createPlanningCalculation } from '../services/leave-planner.service';
import {
BENCHMARK_BUDGET_MULTIPLIERS,
@@ -69,7 +69,7 @@ describe('leave planner benchmark protocol', () => {
),
).toHaveLength(2);
}
// Frozen engine (a871f8f) on 'two years', best of three: the runner-speed yardstick.
// Calibration engine (frozen at a871f8f) on 'two years', best of three: the runner-speed yardstick.
const calibrationRequest = scenarios.find((item) => item.name === 'two years')!.request;
let calibrationMs = Infinity;
for (let run = 0; run < 3; run++) {
@@ -134,5 +134,5 @@ describe('leave planner benchmark protocol', () => {
} finally {
vi.stubGlobal('window', browserWindow);
}
}, 570_000);
}, 210_000);
});
+6 -6
View File
@@ -301,15 +301,15 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
].join('\n');
}
// Measured on the development machine (ratio to the frozen two-years run), tightened by #286.
// About 1.5x the development machine's ratios to the calibration run after #302 (#286).
export const BENCHMARK_BUDGET_MULTIPLIERS: Record<string, number> = {
'representative year': 0.25,
'typed obligations': 3,
'representative year': 0.15,
'typed obligations': 0.1,
'chained earned entitlements': 0.05,
'deliberate conflict': 0.05,
'loaded year': 25,
'two years': 1,
'three years': 3.5,
'loaded year': 2.5,
'two years': 0.5,
'three years': 1.5,
};
/** One message per scenario whose mean planning time exceeds multiplier x calibration, or that has no multiplier. */
@@ -1782,7 +1782,7 @@ describe('CalendarYear planning outcome boundary', () => {
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
now = 21_000;
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 2, unit: 'seconds' });
expect(fixture.nativeElement.querySelector('.loading-overlay')).not.toBeNull();
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(0);
pending.resolve(plan);
+9 -136
View File
@@ -1,7 +1,5 @@
import { afterAll, describe, expect, it } from 'bun:test';
import { createOptimizationPresetRules } from './rule-service';
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
import { OPTIMIZATION_CATALOG } from './planning-objectives';
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
import {
calculatePlanningAsyncInternal,
calculatePlanningInternal,
@@ -12,145 +10,20 @@ import {
calculatePlanningAsyncInternal as frozenCalculateAsync,
calculatePlanningInternal as frozenCalculate,
} from './frozen-engine/leave-planner.core.frozen.mjs';
import {
DAY,
generateRequest,
iso,
mulberry32,
weekday,
type Req,
} from './frozen-engine/random-planning-requests';
/** Rebuild instructions for the frozen engine: docs/benchmark/README.md. */
const BASE_SEED = 280_000;
const CASES = 5000;
const CHUNK = 200;
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const DAY = 86_400_000;
const weekday = (ms: number) => ![0, 6].includes(new Date(ms).getUTCDay());
const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
type Req = Record<string, unknown> & { rules: Record<string, unknown>[] };
function generateRequest(seed: number): Req {
const rnd = mulberry32(seed);
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
const chance = (p: number) => rnd() < p;
const shuffle = <T>(items: T[]): T[] => {
for (let i = items.length - 1; i > 0; i--) {
const j = int(0, i);
[items[i], items[j]] = [items[j], items[i]];
}
return items;
};
const long = chance(0.1);
const mode = rnd();
let startMs = Date.UTC(2025, 0, 1) + int(0, 1094) * DAY;
if (mode < 0.15) startMs = Date.UTC(pick([2025, 2026]), 11, int(8, 31));
else if (mode < 0.3) {
const year = pick([2025, 2026, 2027]);
const holidays = [...frenchPublicHolidaysForYear(year)].filter((d) =>
weekday(Date.parse(d)),
);
startMs = Date.parse(pick(holidays)) - int(1, 10) * DAY;
}
const days = long ? int(29, 70) : int(7, 28);
const start = iso(startMs);
const date = (offset: number) => iso(startMs + offset * DAY);
const types = ['cp', 'rtt', 'other'] as const;
const balances = Array.from({ length: int(1, 3) }, (_, i) => ({
id: `b${i}`,
type: pick(types),
quantity: int(0, long ? 4 : 3),
...(chance(0.3) ? { availableFrom: date(int(1, days)) } : {}),
expiryDate: date(int(-3, days + 40)),
})).map((b) =>
b.availableFrom && b.availableFrom > b.expiryDate
? { ...b, expiryDate: b.availableFrom }
: b,
);
const declaredDays: { date: string; type: string }[] = [];
for (let n = int(0, 4); n > 0; n--) {
const ms = startMs + int(0, days) * DAY;
const d = iso(ms);
if (isWorkingPlanningDate(d) && !declaredDays.some((x) => x.date === d))
declaredDays.push({ date: d, type: pick(types) });
}
const selectors = ['cp', 'rtt', 'other', 'any'] as const;
const rules: Record<string, unknown>[] = [];
for (let n = long ? int(0, 1) : int(0, 2); n > 0; n--) {
const from = int(0, days - 1);
rules.push({
id: `tw${rules.length}`,
type: chance(0.5) ? 'time_window' : 'TIME_WINDOW',
force: pick(['obligation', 'preference']),
startDate: date(from),
endDate: date(from + int(0, 10)),
placeDays: int(1, 4),
placeType: pick(selectors),
});
}
for (let n = long && rules.length ? 0 : long ? int(0, 1) : int(0, 2); n > 0; n--) {
rules.push({
id: `it${rules.length}`,
type: chance(0.5) ? 'if_then' : 'IF_THEN',
force: pick(['obligation', 'preference']),
dayOfWeek: int(1, 5),
placeDays: int(1, 3),
placeType: pick(selectors),
placement: pick(['before', 'after', 'around', 'auto']),
rewardDays: int(0, 2),
rewardType: pick(types),
});
}
const presets: Record<string, unknown>[] = shuffle(
createOptimizationPresetRules() as unknown as Record<string, unknown>[],
).slice(0, long ? int(1, 4) : int(0, 3));
rules.push(...presets);
if (!long && chance(0.3)) {
const row = pick(OPTIMIZATION_CATALOG);
const conditions = (row.conditions as readonly string[]).filter(() => chance(0.5));
rules.push({
id: 'composed',
type: 'optimization',
force: 'preference',
configuration: {
elements: row.elements,
conditions,
measure: row.measure,
aggregation: pick(row.aggregations),
direction: pick(['minimize', 'maximize']),
...(row.measure === 'work-sequence-deficit' ? { thresholdDays: int(1, 8) } : {}),
},
});
}
for (const rule of shuffle(rules)) if (chance(0.08)) rule['enabled'] = false;
const request: Req = {
calculationDate: start,
horizon: { start, end: date(days) },
balances,
declaredDays,
rules,
};
if (chance(0.04)) {
const breakIt = int(0, 3);
if (breakIt === 0) balances[0].quantity = -1;
else if (breakIt === 1) request['horizon'] = { start: date(days), end: start };
else if (breakIt === 2) rules.push({ id: 'bad', type: 'unknown_type' });
else declaredDays.push({ date: start, type: 'nope' });
}
return request;
}
interface Run {
outcome: PlanningOutcome;
artifacts?: Pick<PlanningArtifacts, 'explanation' | 'finalState' | 'explanationError'>;
@@ -0,0 +1,89 @@
// Opt-in before/after record, outside the functional suite and CI: compares the
// current engine with the frozen engine (before its rebuild) or the calibration
// engine (a871f8f) over seeded requests.
// bun src/app/services/frozen-engine/compare-engines.ts [small|loaded-year] [first seed] [count] [frozen|calibration]
import { isDeepStrictEqual } from 'node:util';
import type { PlanningArtifacts, PlanningObjective, PlanningOutcome } from '../leave-planner.core';
import { compareObjectiveValues } from '../planning-objectives';
// The preset rules come from an Angular service file, which needs the JIT compiler outside tests.
await import('@angular/compiler');
const { generateLoadedYearRequest, generateRequest } = await import('./random-planning-requests');
const current = await import('../leave-planner.core');
const baselines = {
frozen: await import('./leave-planner.core.frozen.mjs'),
calibration: await import('./leave-planner.core.calibration.mjs'),
};
const [kind = 'small', first = '280000', count = '5000', baselineName = 'frozen'] =
process.argv.slice(2);
const baseline = baselines[baselineName as keyof typeof baselines];
if (!baseline) throw new Error(`Unknown baseline engine: ${baselineName}`);
const generate = kind === 'loaded-year' ? generateLoadedYearRequest : generateRequest;
type Engine = (
request: unknown,
checkpoint?: undefined,
artifacts?: (artifacts: PlanningArtifacts) => void,
) => PlanningOutcome;
function run(calculate: Engine, request: unknown) {
let artifacts: PlanningArtifacts | undefined;
const started = performance.now();
const outcome = calculate(structuredClone(request), undefined, (value) => (artifacts = value));
return {
ms: performance.now() - started,
result: {
outcome,
explanation: artifacts?.explanation,
finalState: artifacts?.finalState,
explanationError: artifacts?.explanationError,
},
};
}
/** The final plan's objective vector, which every placement explanation repeats. */
function objectives(result: ReturnType<typeof run>['result']): readonly PlanningObjective[] {
return result.explanation?.kind === 'PlanExplanation'
? (result.explanation.placements[0]?.objectiveVector.objectives ?? [])
: [];
}
const counts = new Map<string, number>();
const time = { before: 0, after: 0 };
for (let seed = Number(first); seed < Number(first) + Number(count); seed++) {
const request = generate(seed);
const before = run(baseline.calculatePlanningInternal, request);
const after = run(current.calculatePlanningInternal, request);
time.before += before.ms;
time.after += after.ms;
let category: string;
let detail = '';
if (isDeepStrictEqual(before.result, after.result)) category = 'identical';
else if (before.result.outcome.kind !== after.result.outcome.kind)
category = 'changed outcome kind';
else if (after.result.outcome.kind !== 'Plan') category = 'different non-plan outcome';
else {
const left = objectives(before.result);
const right = objectives(after.result);
const index = right.findIndex((objective, i) => objective.value !== left[i]?.value);
if (index < 0 && left.length === right.length) category = 'same objective values';
else if (index < 0 || left[index]?.id !== right[index].id) category = 'not comparable';
else {
const objective = right[index];
const better =
compareObjectiveValues(objective.direction, objective.value, left[index].value) < 0;
const tieBreak = ['balance-expiry', 'placement-date'].includes(objective.id);
category = `${better ? 'better' : 'worse'}${tieBreak ? ' (tie-break only)' : ''}`;
detail = `\t${objective.id}\t${left[index].value} -> ${objective.value}`;
}
}
if (category !== 'identical') console.log(`${seed}\t${category}${detail}`);
counts.set(category, (counts.get(category) ?? 0) + 1);
}
console.log(
`${kind} requests, seeds ${first}..${Number(first) + Number(count) - 1}, against the ${baselineName} engine`,
);
for (const [category, total] of [...counts].sort()) console.log(`${category}\t${total}`);
console.log(
`${baselineName} engine ${(time.before / 1000).toFixed(1)} s, current engine ${(time.after / 1000).toFixed(1)} s`,
);
@@ -0,0 +1,22 @@
import type {
PlanningArtifactsCallback,
PlanningCheckpoint,
PlanningExplanationCallback,
PlanningOutcome,
PlanningOutcomeCallback,
} from '../leave-planner.core';
export function calculatePlanningInternal(
request: unknown,
checkpointCallback?: undefined,
artifactsCallback?: PlanningArtifactsCallback,
): PlanningOutcome;
export function calculatePlanningAsyncInternal(
request: unknown,
checkpointCallback?: PlanningCheckpoint,
artifactsCallback?: PlanningArtifactsCallback,
outcomeCallback?: PlanningOutcomeCallback,
explanationCallback?: PlanningExplanationCallback,
isCancelled?: () => boolean,
): Promise<PlanningOutcome>;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,179 @@
// Seeded planning requests shared by the frozen-engine check and the engine comparison.
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from '../declared-non-working-days';
import { OPTIMIZATION_CATALOG } from '../planning-objectives';
import { createOptimizationPresetRules } from '../rule-service';
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const DAY = 86_400_000;
export const weekday = (ms: number) => ![0, 6].includes(new Date(ms).getUTCDay());
export const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
export type Req = Record<string, unknown> & { rules: Record<string, unknown>[] };
export function generateRequest(seed: number): Req {
const rnd = mulberry32(seed);
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
const chance = (p: number) => rnd() < p;
const shuffle = <T>(items: T[]): T[] => {
for (let i = items.length - 1; i > 0; i--) {
const j = int(0, i);
[items[i], items[j]] = [items[j], items[i]];
}
return items;
};
const long = chance(0.1);
const mode = rnd();
let startMs = Date.UTC(2025, 0, 1) + int(0, 1094) * DAY;
if (mode < 0.15) startMs = Date.UTC(pick([2025, 2026]), 11, int(8, 31));
else if (mode < 0.3) {
const year = pick([2025, 2026, 2027]);
const holidays = [...frenchPublicHolidaysForYear(year)].filter((d) =>
weekday(Date.parse(d)),
);
startMs = Date.parse(pick(holidays)) - int(1, 10) * DAY;
}
const days = long ? int(29, 70) : int(7, 28);
const start = iso(startMs);
const date = (offset: number) => iso(startMs + offset * DAY);
const types = ['cp', 'rtt', 'other'] as const;
const balances = Array.from({ length: int(1, 3) }, (_, i) => ({
id: `b${i}`,
type: pick(types),
quantity: int(0, long ? 4 : 3),
...(chance(0.3) ? { availableFrom: date(int(1, days)) } : {}),
expiryDate: date(int(-3, days + 40)),
})).map((b) =>
b.availableFrom && b.availableFrom > b.expiryDate
? { ...b, expiryDate: b.availableFrom }
: b,
);
const declaredDays: { date: string; type: string }[] = [];
for (let n = int(0, 4); n > 0; n--) {
const ms = startMs + int(0, days) * DAY;
const d = iso(ms);
if (isWorkingPlanningDate(d) && !declaredDays.some((x) => x.date === d))
declaredDays.push({ date: d, type: pick(types) });
}
const selectors = ['cp', 'rtt', 'other', 'any'] as const;
const rules: Record<string, unknown>[] = [];
for (let n = long ? int(0, 1) : int(0, 2); n > 0; n--) {
const from = int(0, days - 1);
rules.push({
id: `tw${rules.length}`,
type: chance(0.5) ? 'time_window' : 'TIME_WINDOW',
force: pick(['obligation', 'preference']),
startDate: date(from),
endDate: date(from + int(0, 10)),
placeDays: int(1, 4),
placeType: pick(selectors),
});
}
for (let n = long && rules.length ? 0 : long ? int(0, 1) : int(0, 2); n > 0; n--) {
rules.push({
id: `it${rules.length}`,
type: chance(0.5) ? 'if_then' : 'IF_THEN',
force: pick(['obligation', 'preference']),
dayOfWeek: int(1, 5),
placeDays: int(1, 3),
placeType: pick(selectors),
placement: pick(['before', 'after', 'around', 'auto']),
rewardDays: int(0, 2),
rewardType: pick(types),
});
}
const presets: Record<string, unknown>[] = shuffle(
createOptimizationPresetRules() as unknown as Record<string, unknown>[],
).slice(0, long ? int(1, 4) : int(0, 3));
rules.push(...presets);
if (!long && chance(0.3)) {
const row = pick(OPTIMIZATION_CATALOG);
const conditions = (row.conditions as readonly string[]).filter(() => chance(0.5));
rules.push({
id: 'composed',
type: 'optimization',
force: 'preference',
configuration: {
elements: row.elements,
conditions,
measure: row.measure,
aggregation: pick(row.aggregations),
direction: pick(['minimize', 'maximize']),
...(row.measure === 'work-sequence-deficit' ? { thresholdDays: int(1, 8) } : {}),
},
});
}
for (const rule of shuffle(rules)) if (chance(0.08)) rule['enabled'] = false;
const request: Req = {
calculationDate: start,
horizon: { start, end: date(days) },
balances,
declaredDays,
rules,
};
if (chance(0.04)) {
const breakIt = int(0, 3);
if (breakIt === 0) balances[0].quantity = -1;
else if (breakIt === 1) request['horizon'] = { start: date(days), end: start };
else if (breakIt === 2) rules.push({ id: 'bad', type: 'unknown_type' });
else declaredDays.push({ date: start, type: 'nope' });
}
return request;
}
/**
* Loaded-year-sized requests: one year, four to eight balances and two to four
* obligation windows of two or three days. Their obligation search always stops
* at its solution limit, so the calibration engine previews at most eight solutions.
*/
export function generateLoadedYearRequest(seed: number): Req {
const rnd = mulberry32(seed);
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
const year = int(2024, 2026);
const start = `${year}-01-01`;
const months = Array.from({ length: 12 }, (_, index) => index + 1);
for (let i = months.length - 1; i > 0; i--) {
const j = int(0, i);
[months[i], months[j]] = [months[j], months[i]];
}
const obligations = int(2, 4);
const windows = months.slice(0, obligations + int(0, 2)).map((month, index) => ({
id: `window-${index}`,
type: 'time_window',
force: index < obligations ? 'obligation' : 'preference',
startDate: `${year}-${String(month).padStart(2, '0')}-01`,
endDate: `${year}-${String(month).padStart(2, '0')}-28`,
placeDays: index < obligations ? int(2, 3) : int(1, 4),
placeType: pick(['any', 'cp', 'rtt']),
}));
const presets = createOptimizationPresetRules() as unknown as Record<string, unknown>[];
return {
calculationDate: start,
horizon: { start, end: `${year + 1}-01-01` },
balances: Array.from({ length: int(4, 8) }, (_, index) => ({
id: `b${index}`,
type: pick(['cp', 'rtt']),
quantity: int(6, 14),
availableFrom: start,
expiryDate: `${year}-12-31`,
})),
declaredDays: [],
rules: [presets[0], ...windows, ...presets.slice(1)],
};
}
@@ -495,6 +495,53 @@ describe('planner explanation and alternative dates', () => {
).toBeUndefined();
});
it('judges each alternative producer date against the unchanged final plan', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2026-04-20', end: '2026-05-31' },
balances: [balance('cp', 'cp', 2, '2026-12-31', '2026-04-20')],
rules: [
{
id: 'friday',
type: 'if_then',
force: 'preference',
dayOfWeek: 5,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2026-05-12',
endDate: '2026-05-12',
placeDays: 1,
placeType: 'rtt',
},
],
});
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const producer = outcome.placements.find(
(placement) => placement.date === '2026-04-30' && placement.ruleIds.includes('friday'),
);
expect(producer).toBeDefined();
if (!producer) return;
const analysis = await calculation.analyzeCounterfactual(producer);
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual.');
// 2026-05-04 is evaluated first; the move to 2026-05-11 must not inherit its dates.
expect(analysis.results.find((result) => result.date === '2026-05-04')?.status).toBe(
'eligible',
);
expect(analysis.results.find((result) => result.date === '2026-05-11')).toMatchObject({
status: 'ineligible',
reason: { code: 'unavailable' },
});
});
it('rejects an at-risk move that would delay already-earned leave past its consumer', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-08' },
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'bun:test';
import { calculatePlanningInternal, type PlanningRequest } from './leave-planner.core';
import { createOptimizationPresetRules } from './rule-service';
const presets = createOptimizationPresetRules();
const preset = (id: string) => presets.find((rule) => rule.id === id)!;
describe('choosing among obligation solutions', () => {
it('plans from the solution ranked best before the preferences', () => {
// The obligation day can take either balance. Before the preferences, the
// at-risk RTT ranks first because it leaves no at-risk leave unplaced. Funding
// it with the CP instead would let the RTT gain three days around Easter
// later; ADR 0010 accepts that loss to run the preferences only once.
const request: PlanningRequest & { calculationDate: string } = {
calculationDate: '2026-04-03',
horizon: { start: '2026-04-03', end: '2026-05-24' },
balances: [
{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2026-06-30' },
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2026-05-23' },
],
rules: [
preset('default-unplaced-at-risk'),
preset('default-supporting-placements'),
preset('default-non-working-day-gain'),
{
id: 'thursday',
type: 'time_window',
force: 'obligation',
startDate: '2026-04-09',
endDate: '2026-04-09',
placeDays: 1,
placeType: 'any',
},
],
};
const outcome = calculatePlanningInternal(request);
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect(outcome.placements).toEqual([
{
date: '2026-04-09',
balanceId: 'rtt',
type: 'rtt',
ruleIds: ['thursday'],
supporting: false,
},
]);
expect(outcome.metrics.unplacedAtRisk).toBe(0);
});
it('plans from the next solution when the best-ranked one earns its leave too late', () => {
const request: PlanningRequest & { calculationDate: string } = {
calculationDate: '2026-07-09',
horizon: { start: '2026-07-09', end: '2026-07-25' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2026-07-13',
expiryDate: '2026-08-10',
},
],
rules: [
{
id: 'tuesday',
type: 'if_then',
force: 'preference',
dayOfWeek: 2,
placeDays: 1,
placeType: 'any',
placement: 'auto',
rewardDays: 2,
rewardType: 'rtt',
},
{
id: 'late-july',
type: 'time_window',
force: 'obligation',
startDate: '2026-07-23',
endDate: '2026-08-02',
placeDays: 2,
placeType: 'any',
},
],
};
const outcome = calculatePlanningInternal(request);
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect(
outcome.placements.map(({ date, balanceId, ruleIds }) => [date, balanceId, ruleIds]),
).toEqual([
['2026-07-13', 'cp', ['tuesday']],
['2026-07-23', 'cp', ['late-july']],
['2026-07-24', 'cp', ['late-july']],
]);
});
});
@@ -360,7 +360,9 @@ describe('supporting placement economy and provenance', () => {
}),
expect.objectContaining({
date: '2024-05-10',
ruleIds: ['gain'],
// The best-ranked obligation solution placed this date for the producer's
// after-pattern before gain ran, so the date keeps no gain credit (ADR 0010).
ruleIds: force === 'obligation' ? [] : ['gain'],
supporting: true,
}),
]);
+147 -158
View File
@@ -24,7 +24,7 @@ import { addPlanningYears, isPlanningDate, planningDateInParis } from './plannin
import {
compareObjectiveLists,
compareObjectiveValues,
evaluateOptimization,
evaluateValidatedOptimization,
firstDifferentObjective,
OPTIMIZATION_PRESETS,
validateOptimizationConfiguration,
@@ -544,12 +544,8 @@ interface SearchContext {
observerTime: number;
completedPreferenceStates: Map<number, string>;
completedGenericState?: string;
/** Supplied metrics were evaluated after reconciling earned entitlement dates. */
retain?: (
state: State,
finalizedMetrics?: PlanningMetrics,
vector?: PlanningObjectiveVector,
) => void;
/** A supplied vector was evaluated after reconciling earned entitlement dates. */
retain?: (state: State, vector?: PlanningObjectiveVector) => void;
}
type PlanningCalendar = Pick<
@@ -562,13 +558,6 @@ type PlanningCalendar = Pick<
| 'workingPrefix'
>;
interface ObligationPreview {
obligationState: State;
state: State;
preferences: PreferenceResult[];
metrics: PlanningMetrics;
}
function now(): number {
return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
}
@@ -588,12 +577,11 @@ class UserCancelled extends Error {}
// A true value asks the asynchronous driver to pause immediately at a phase boundary.
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
// ponytail: cap the number of feasible obligation branches previewed for a
// ponytail: cap the number of feasible obligation branches ranked for a
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
// that this ceiling misses a better lexicographic assignment.
const OBLIGATION_SOLUTION_LIMIT = 4096;
const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000;
const WINDOW_OBLIGATION_PREVIEW_LIMIT = 8;
const VALID_TYPES = new Set<LeaveType>(['cp', 'rtt', 'other']);
const VALID_SELECTORS = new Set<LeaveSelector>(['cp', 'rtt', 'other', 'any']);
const VALID_MODES = new Set<PlacementMode>(['before', 'after', 'around', 'auto']);
@@ -700,10 +688,8 @@ 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(
function copyPlacements(state: State): State['placements'] {
return new Map(
[...state.placements.entries()].map(([date, placement]) => [
date,
{
@@ -712,10 +698,14 @@ function cloneState(state: State, placements?: State['placements']): State {
},
]),
);
derivePlacements(state.placements, clonedPlacements);
}
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],
@@ -723,6 +713,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,
@@ -1462,18 +1462,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,
};
});
}
@@ -1501,18 +1503,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(
@@ -1869,7 +1873,15 @@ function reconcileOptimizationProvenance(
);
if (!credits.length) continue;
const withoutState = releasePlacement(state, placement.date);
const withoutPlacement = activeObjectiveVector(withoutState, context);
// Only credited objectives are compared, so skip observing the others.
const withoutPlacement = [
...activeObjectives(
withoutState,
context,
Number.POSITIVE_INFINITY,
new Set(credits.map(({ id }) => id)),
),
];
const ruleIds = new Set(
[...placement.ruleIds].filter(
(id) => !credits.some((objective) => objective.id === `optimization:${id}`),
@@ -1892,11 +1904,12 @@ function reconcileOptimizationProvenance(
// Another tied maximum can mask a placement's improvement to its own element.
observations ??= optimizationObservations(state, context);
const withoutObservations = optimizationObservations(withoutState, context);
// Every catalog row accepts the sum, so this stays a valid configuration.
const summed = { ...rule.configuration, aggregation: 'sum' as const };
const summedComparison = compareObjectiveValues(
objective.direction,
evaluateOptimization(summed, observations),
evaluateOptimization(summed, withoutObservations),
evaluateValidatedOptimization(summed, observations),
evaluateValidatedOptimization(summed, withoutObservations),
);
contributes =
summedComparison < 0 ||
@@ -1926,14 +1939,14 @@ function supportingCalendarBenefit(
): boolean {
const observations = optimizationObservations(after, context);
return (
evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, observations) >
evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, before) ||
evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, observations) >
evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, before) ||
evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations) <
evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, before) ||
evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations) <
evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, before)
evaluateValidatedOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, observations) >
evaluateValidatedOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, before) ||
evaluateValidatedOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, observations) >
evaluateValidatedOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, before) ||
evaluateValidatedOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations) <
evaluateValidatedOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, before) ||
evaluateValidatedOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations) <
evaluateValidatedOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, before)
);
}
@@ -2208,6 +2221,7 @@ function* calendarPlacementCandidates(
ruleId: string,
context: SearchContext,
accept: (candidate: State, addition?: boolean) => boolean,
donorObjectives = new Map<string, PlanningObjective[]>(),
): PlanningSteps<boolean> {
const missing = targetDates.filter(
(date) => !state.placements.has(date) && !context.declaredDates.has(date),
@@ -2221,10 +2235,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 +2346,17 @@ function* holidayWeekPlacementCandidates(
context: SearchContext,
accept: (candidate: State, addition?: boolean) => boolean,
): PlanningSteps<void> {
// Releasing a donor scores the same for every week of this plan.
const donorObjectives = new Map<string, PlanningObjective[]>();
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(
@@ -2456,7 +2481,7 @@ function evaluateRuleCandidate(
const vector = objectiveVector(state, context, active);
// Preserve the best valid candidate even if the active prefix rejects it.
if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0)
context.retain?.(state, undefined, vector);
context.retain?.(state, vector);
// shortcut: single additions cannot cross equal-value plateaus; add coordinated balance bundles when a measured configuration needs them.
if (addition && comparison === 0) return undefined;
return { state, vector };
@@ -2638,7 +2663,7 @@ function* optimizeRulePlacements(
item.configuration.measure === 'public-holiday-week-value' &&
item.configuration.direction === 'maximize'
)
return evaluateOptimization(item.configuration, {
return evaluateValidatedOptimization(item.configuration, {
leaveBalances: [],
publicHolidayWeeks: context.holidayWeeks.map(({ value }) => ({
value,
@@ -2664,7 +2689,7 @@ function* optimizeRulePlacements(
const active = activeObjectiveVector(result, context);
const baseline = active.slice(0, throughIndex + 1);
const beforeVector = objectiveVector(result, context, active);
context.retain?.(result, undefined, beforeVector);
context.retain?.(result, beforeVector);
if (
allowAdd &&
prefixBounds.length > 0 &&
@@ -3260,26 +3285,6 @@ function preferenceReason(
return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]);
}
function* applyPreferenceRules(
state: State,
preferenceRules: NormalizedRule[],
context: SearchContext,
): PlanningSteps<{ state: State; preferences: PreferenceResult[] }> {
let result = state;
const preferences: PreferenceResult[] = [];
for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) {
yield;
result = yield* applyPreferenceStep(
result,
preferenceRules,
preferenceIndex,
preferences,
context,
);
}
return { state: result, preferences };
}
function* applyPreferenceStep(
state: State,
preferenceRules: NormalizedRule[],
@@ -3383,6 +3388,7 @@ function* activeObjectives(
state: State,
context: SearchContext,
throughIndex = Number.POSITIVE_INFINITY,
only?: ReadonlySet<PlanningObjectiveId>,
): Generator<PlanningObjective> {
let preferences: PreferenceResult[] | undefined;
let balances: OptimizationObservations | undefined;
@@ -3391,6 +3397,11 @@ function* activeObjectives(
for (const rule of context.rules) {
if (!rule.enabled || rule.force !== 'preference') continue;
if (index++ > throughIndex) return;
if (
only &&
!only.has(`${rule.type === 'optimization' ? 'optimization' : 'preference'}:${rule.id}`)
)
continue;
if (rule.type === 'optimization') {
const observations =
rule.configuration.elements === 'leave-balances'
@@ -4098,16 +4109,32 @@ export async function calculatePlanningAsyncInternal(
explanationCallback,
true,
);
// Slices yield so cancellation and heartbeat tasks can run, without a timer:
// browsers clamp nested timers to 4 ms, so they yield through a message task.
// Bun runs port messages ahead of timers and worker messages, so it uses setImmediate.
const channel = typeof setImmediate === 'function' ? undefined : new MessageChannel();
let resume = () => {};
if (channel) channel.port1.onmessage = () => resume();
const pause = () =>
new Promise<void>((resolve) => {
if (!channel) return void setImmediate(resolve);
resume = resolve;
channel.port2.postMessage(undefined);
});
try {
let next = steps.next();
let pauseAt = now() + 8;
while (!next.done) {
if (next.value || now() >= pauseAt) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
await pause();
pauseAt = now() + 8;
}
next = isCancelled() ? steps.throw(new UserCancelled()) : steps.next();
}
return next.value;
} finally {
channel?.port1.close();
}
}
function* prepareCalendar(
@@ -4245,17 +4272,14 @@ function* calculatePlanningSteps(
let checkpoint: PlanOutcome | undefined;
let checkpointState: State | undefined;
let retained:
| (Pick<ObligationPreview, 'state' | 'preferences' | 'metrics'> & {
vector: PlanningObjectiveVector;
})
| { state: State; preferences: PreferenceResult[]; vector: PlanningObjectiveVector }
| undefined;
if (retainIncumbents)
context.retain = (candidate, finalizedMetrics, candidateVector) => {
context.retain = (candidate, candidateVector) => {
const check = context.check;
context.check = () => undefined;
try {
const finalized =
finalizedMetrics || candidateVector
const finalized = candidateVector
? candidate
: finalizeEarnedState(candidate, context);
if (!finalized) return;
@@ -4302,14 +4326,6 @@ function* calculatePlanningSteps(
),
vector,
preferences: candidatePreferences,
metrics: finalizedMetrics
? {
...finalizedMetrics,
preferenceSatisfaction: candidatePreferences.map(
({ satisfied }) => satisfied,
),
}
: qualityMetrics(finalized, context, candidatePreferences),
};
if (firstValidCandidate && checkpointCallback)
observeCallback(context, () =>
@@ -4505,15 +4521,9 @@ function* calculatePlanningSteps(
const preferenceRules = normalized.rules.filter(
(item) => item.enabled && item.force === 'preference',
);
let previewSolutions = obligationSolutions;
if (
obligationSolutionLimit > 0 &&
obligationRequirements.every(
(requirement) => requirement.rule.type === 'time_window',
) &&
obligationSolutions.length > WINDOW_OBLIGATION_PREVIEW_LIMIT
) {
// shortcut: large window searches preview the eight best active vectors; upgrade to shared bounded previews when a measured case misses a better plan.
let rankedSolutions = obligationSolutions;
if (obligationSolutions.length > 1) {
// shortcut: preferences run on the best-ranked obligation-only plan only, so a lower-ranked one that would end better is missed (ADR 0010); revisit if the comparison record grows.
const ranked: { state: State; objectives: PlanningObjective[] }[] = [];
for (const candidate of obligationSolutions) {
yield;
@@ -4523,57 +4533,23 @@ function* calculatePlanningSteps(
objectives: obligationPreviewObjectives({ state: candidate }, context),
});
}
// The sort is stable, so equal ranks keep enumeration order.
ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives));
previewSolutions = ranked
.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT)
.map(({ state }) => state);
rankedSolutions = ranked.map(({ state }) => state);
}
// A completed enumeration with one finalized solution has no alternative to rank.
const soleSolution =
obligationSolutions.length === 1 &&
(obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit)
? obligationSolutions[0]
: undefined;
const solePreferences = soleSolution
? preferenceResultsForState(soleSolution, context)
: [];
let bestPreview: ObligationPreview | null = soleSolution
? {
obligationState: soleSolution,
state: soleSolution,
preferences: solePreferences,
metrics: qualityMetrics(soleSolution, context, solePreferences),
}
: null;
for (const obligationSolution of soleSolution ? [] : previewSolutions) {
yield;
context.check();
const preview = yield* applyPreferenceRules(
cloneState(obligationSolution),
preferenceRules,
context,
);
const previewState = finalizeEarnedState(preview.state, context);
if (!previewState) continue;
const candidate = {
obligationState: obligationSolution,
state: previewState,
preferences: preview.preferences,
metrics: qualityMetrics(previewState, context, preview.preferences),
};
const compared: number | undefined = bestPreview
? compareObligationPreviews(candidate, bestPreview, context)
: undefined;
if (!bestPreview || compared! < 0) bestPreview = candidate;
}
if (!bestPreview) {
stages.push('obligations');
return finish(obligationConflict(obligationRequirements, context));
}
state = bestPreview.obligationState;
for (const obligationSolution of rankedSolutions) {
// A solution whose earned leave comes too late restarts the stages on the next one.
state = obligationSolution;
stages.length = 1;
preferences.length = 0;
checkpointState = undefined;
stages.push('obligations');
publishCheckpoint();
for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) {
for (
let preferenceIndex = 0;
preferenceIndex < preferenceRules.length;
preferenceIndex++
) {
yield;
const rule = preferenceRules[preferenceIndex];
markProgress(context, ruleStage(rule));
@@ -4590,14 +4566,28 @@ function* calculatePlanningSteps(
}
markProgress(context, 'expiry');
yield true;
state = yield* optimizeRulePlacements(state, context, preferenceRules.length - 1, false);
state = yield* optimizeRulePlacements(
state,
context,
preferenceRules.length - 1,
false,
);
stages.push('expiry');
publishCheckpoint();
markProgress(context, 'dates');
yield true;
stages.push('dates');
const finalized = finalizeEarnedState(state, context);
if (!finalized) {
if (!finalized) continue;
state = finalized;
publishCheckpoint();
markProgress(context, 'complete');
yield true;
return finish(checkpoint!, state);
}
// As when every preview failed, several failing solutions report the obligation conflict.
if (rankedSolutions.length > 1)
return finish(obligationConflict(obligationRequirements, context));
return finish({
kind: 'PlanningConflict',
conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId),
@@ -4608,12 +4598,6 @@ function* calculatePlanningSteps(
),
],
});
}
state = finalized;
publishCheckpoint();
markProgress(context, 'complete');
yield true;
return finish(checkpoint!, state);
} catch (error) {
if (error instanceof UserCancelled && retained) {
// Freeze the chosen valid candidate, rather than exposing a temporary search state.
@@ -5128,13 +5112,18 @@ export function analyzeCounterfactualInternal(
continue;
}
candidate.placements.get(date)!.supporting = selected.supporting;
// The candidate shares its activations with the baseline, so replace them rather than edit them.
if (removeDate && removeDate !== date)
for (const activation of candidate.earnedActivations) {
if (!selected.ruleIds.includes(activation.ruleId)) continue;
activation.dates = activation.dates.map((activationDate) =>
candidate.earnedActivations = candidate.earnedActivations.map((activation) =>
selected.ruleIds.includes(activation.ruleId)
? {
...activation,
dates: activation.dates.map((activationDate) =>
activationDate === removeDate ? date : activationDate,
);
),
}
: activation,
);
const finalized = finalizeEarnedState(candidate, context);
if (!finalized) {
results.push({
+3 -2
View File
@@ -1,7 +1,7 @@
import { isWorkingPlanningDate } from './declared-non-working-days';
import type { TimeOffPeriod } from './leave-planner.core';
import {
evaluateOptimization,
evaluateValidatedOptimization,
type OptimizationConfiguration,
type OptimizationObservations,
} from './planning-objectives';
@@ -683,11 +683,12 @@ export function workSequenceDates(state: ObservedPlan, context: ObservationCalen
}));
}
/** The configuration was validated when the calculation's rules were normalized. */
export function evaluateStateOptimization(
configuration: OptimizationConfiguration,
state: ObservedPlan,
context: ObservationCalendar,
observations = optimizationObservations(state, context),
): number {
return evaluateOptimization(configuration, observations);
return evaluateValidatedOptimization(configuration, observations);
}
@@ -31,7 +31,7 @@ describe('Planning duration estimate', () => {
it('offers a coarse seconds range on the first launch without calculating', () => {
const duration = TestBed.inject(PlanningDurationService);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 2, unit: 'seconds' });
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
expect(TestBed.inject(RuleService).rules).toEqual([]);
@@ -133,7 +133,7 @@ describe('Planning duration estimate', () => {
TestBed.resetTestingModule();
expect(TestBed.inject(PlanningDurationService).estimate()).toEqual({
minimum: 1,
maximum: 3,
maximum: 2,
unit: 'seconds',
});
});
@@ -44,9 +44,9 @@ function initialDurationMs(request: PlanningRequest): number {
Math.max(1, 'placeDays' in rule ? rule.placeDays / 3 : 1);
if ('rewardDays' in rule) placements += (Math.max(0, rule.rewardDays) * days) / 365.25;
}
// Rough prior from the browser references in docs/planning-duration.md, not a deadline.
// Rough prior fitted to the 2026-10-18 rendered waits in docs/planning-duration.md, not a deadline.
const duration =
300 + 600 * (days / 365.25) ** 1.2 * (Math.max(1, placements) / 30) ** 1.5 * ruleWeight;
400 + 250 * (days / 365.25) ** 0.5 * (Math.max(1, placements) / 30) ** 1.5 * ruleWeight;
return Number.isFinite(duration) ? duration : 1_000;
}
+8
View File
@@ -212,6 +212,14 @@ export function evaluateOptimization(
): number {
const validation = validateOptimizationConfiguration(configuration);
if (!validation.valid) throw new Error(`${validation.code}: ${validation.message}`);
return evaluateValidatedOptimization(configuration, observations);
}
/** Scores a configuration that {@link validateOptimizationConfiguration} already accepted. */
export function evaluateValidatedOptimization(
configuration: OptimizationConfiguration,
observations: OptimizationObservations,
): number {
const selected = (condition: OptimizationCondition) =>
configuration.conditions.includes(condition);
let values: number[];