Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee11371f41 | ||
|
|
7fc3457b63 | ||
|
|
31cb4a499c | ||
|
|
75320da685 | ||
|
|
fe705fe618 | ||
|
|
676ea24410 | ||
|
|
6c9ff4ae34 | ||
|
|
8168137cd5 | ||
|
|
aa6a0c6f1b | ||
|
|
9d9404c3db | ||
|
|
b7ec411c92 | ||
|
|
2fcba758c8 | ||
|
|
0b0bb8f9a8 | ||
|
|
b356117263 | ||
|
|
ba05fe88b3 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -46,3 +46,7 @@ Thumbs.db
|
||||
|
||||
# Auto-generated i18n files
|
||||
/src/app/i18n-registry.ts
|
||||
|
||||
# Claude Code local files (.claude is a symlink to .agents/)
|
||||
.agents/settings.local.json
|
||||
.agents/worktrees/
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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,22 +688,24 @@ function reason(code: string, message: string, ruleIds?: string[]): PlanningReas
|
||||
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
|
||||
}
|
||||
|
||||
function cloneState(state: State, placements?: State['placements']): State {
|
||||
const clonedPlacements =
|
||||
placements ??
|
||||
new Map(
|
||||
[...state.placements.entries()].map(([date, placement]) => [
|
||||
date,
|
||||
{
|
||||
...placement,
|
||||
ruleIds: new Set(placement.ruleIds),
|
||||
},
|
||||
]),
|
||||
);
|
||||
derivePlacements(state.placements, clonedPlacements);
|
||||
function copyPlacements(state: State): State['placements'] {
|
||||
return new Map(
|
||||
[...state.placements.entries()].map(([date, placement]) => [
|
||||
date,
|
||||
{
|
||||
...placement,
|
||||
ruleIds: new Set(placement.ruleIds),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneState(state: State): State {
|
||||
const placements = copyPlacements(state);
|
||||
derivePlacements(state.placements, placements);
|
||||
return {
|
||||
balances: state.balances.map((balance) => ({ ...balance })),
|
||||
placements: clonedPlacements,
|
||||
placements,
|
||||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||||
...activation,
|
||||
dates: [...activation.dates],
|
||||
@@ -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,
|
||||
);
|
||||
let next = steps.next();
|
||||
let pauseAt = now() + 8;
|
||||
while (!next.done) {
|
||||
if (next.value || now() >= pauseAt) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
pauseAt = now() + 8;
|
||||
// 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 pause();
|
||||
pauseAt = now() + 8;
|
||||
}
|
||||
next = isCancelled() ? steps.throw(new UserCancelled()) : steps.next();
|
||||
}
|
||||
next = isCancelled() ? steps.throw(new UserCancelled()) : steps.next();
|
||||
return next.value;
|
||||
} finally {
|
||||
channel?.port1.close();
|
||||
}
|
||||
return next.value;
|
||||
}
|
||||
|
||||
function* prepareCalendar(
|
||||
@@ -4245,19 +4272,16 @@ 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
|
||||
? candidate
|
||||
: finalizeEarnedState(candidate, context);
|
||||
const finalized = candidateVector
|
||||
? candidate
|
||||
: finalizeEarnedState(candidate, context);
|
||||
if (!finalized) return;
|
||||
if (unsatisfiedObligationCount(finalized, context)) return;
|
||||
if (
|
||||
@@ -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,97 +4533,71 @@ 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) {
|
||||
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');
|
||||
return finish(obligationConflict(obligationRequirements, context));
|
||||
}
|
||||
state = bestPreview.obligationState;
|
||||
stages.push('obligations');
|
||||
publishCheckpoint();
|
||||
for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) {
|
||||
yield;
|
||||
const rule = preferenceRules[preferenceIndex];
|
||||
markProgress(context, ruleStage(rule));
|
||||
yield true;
|
||||
state = yield* applyPreferenceStep(
|
||||
state,
|
||||
preferenceRules,
|
||||
preferenceIndex,
|
||||
preferences,
|
||||
context,
|
||||
);
|
||||
stages.push(ruleStage(rule));
|
||||
publishCheckpoint();
|
||||
for (
|
||||
let preferenceIndex = 0;
|
||||
preferenceIndex < preferenceRules.length;
|
||||
preferenceIndex++
|
||||
) {
|
||||
yield;
|
||||
const rule = preferenceRules[preferenceIndex];
|
||||
markProgress(context, ruleStage(rule));
|
||||
yield true;
|
||||
state = yield* applyPreferenceStep(
|
||||
state,
|
||||
preferenceRules,
|
||||
preferenceIndex,
|
||||
preferences,
|
||||
context,
|
||||
);
|
||||
stages.push(ruleStage(rule));
|
||||
publishCheckpoint();
|
||||
}
|
||||
markProgress(context, 'expiry');
|
||||
yield true;
|
||||
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) continue;
|
||||
state = finalized;
|
||||
publishCheckpoint();
|
||||
markProgress(context, 'complete');
|
||||
yield true;
|
||||
return finish(checkpoint!, state);
|
||||
}
|
||||
markProgress(context, 'expiry');
|
||||
yield true;
|
||||
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) {
|
||||
return finish({
|
||||
kind: 'PlanningConflict',
|
||||
conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId),
|
||||
reasons: [
|
||||
reason(
|
||||
'earned-entitlement-order',
|
||||
'Generated leave became available only after its final merged time-off period.',
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
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),
|
||||
reasons: [
|
||||
reason(
|
||||
'earned-entitlement-order',
|
||||
'Generated leave became available only after its final merged time-off period.',
|
||||
),
|
||||
],
|
||||
});
|
||||
} 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) =>
|
||||
activationDate === removeDate ? date : 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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user