diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8f22220..549943f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index c1cce13..953db94 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/README.md b/README.md index 7fb115b..94499ae 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ Open . 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. diff --git a/docs/adr/0003-constraint-first-lexicographic-planning.md b/docs/adr/0003-constraint-first-lexicographic-planning.md index e475ecb..5a6cf35 100644 --- a/docs/adr/0003-constraint-first-lexicographic-planning.md +++ b/docs/adr/0003-constraint-first-lexicographic-planning.md @@ -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. diff --git a/docs/adr/0010-rank-obligation-solutions-before-preferences.md b/docs/adr/0010-rank-obligation-solutions-before-preferences.md new file mode 100644 index 0000000..4bf77c4 --- /dev/null +++ b/docs/adr/0010-rank-obligation-solutions-before-preferences.md @@ -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. diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md index a1e4d7b..4e8b78a 100644 --- a/docs/benchmark/README.md +++ b/docs/benchmark/README.md @@ -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. diff --git a/docs/benchmark/issue-302.md b/docs/benchmark/issue-302.md new file mode 100644 index 0000000..bc8207b --- /dev/null +++ b/docs/benchmark/issue-302.md @@ -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. diff --git a/docs/gitea-actions.md b/docs/gitea-actions.md index b03111f..6c2f4f7 100644 --- a/docs/gitea-actions.md +++ b/docs/gitea-actions.md @@ -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. diff --git a/docs/planner-cancellation.md b/docs/planner-cancellation.md index ad85c0c..1509d6c 100644 --- a/docs/planner-cancellation.md +++ b/docs/planner-cancellation.md @@ -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. diff --git a/docs/planning-duration.md b/docs/planning-duration.md index 6271a2d..62b4e9a 100644 --- a/docs/planning-duration.md +++ b/docs/planning-duration.md @@ -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 diff --git a/docs/planning-objectives.md b/docs/planning-objectives.md index b5911f6..6254d92 100644 --- a/docs/planning-objectives.md +++ b/docs/planning-objectives.md @@ -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 diff --git a/src/app/benchmark/leave-planner-benchmark.spec.ts b/src/app/benchmark/leave-planner-benchmark.spec.ts index c73dc43..5ca8a95 100644 --- a/src/app/benchmark/leave-planner-benchmark.spec.ts +++ b/src/app/benchmark/leave-planner-benchmark.spec.ts @@ -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); }); diff --git a/src/app/benchmark/leave-planner-benchmark.ts b/src/app/benchmark/leave-planner-benchmark.ts index a655a6b..c1ae286 100644 --- a/src/app/benchmark/leave-planner-benchmark.ts +++ b/src/app/benchmark/leave-planner-benchmark.ts @@ -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 = { - '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. */ diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index 42ab989..fefddd7 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -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); diff --git a/src/app/services/frozen-engine.spec.ts b/src/app/services/frozen-engine.spec.ts index a872711..ef29c70 100644 --- a/src/app/services/frozen-engine.spec.ts +++ b/src/app/services/frozen-engine.spec.ts @@ -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 & { rules: Record[] }; - -function generateRequest(seed: number): Req { - const rnd = mulberry32(seed); - const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1)); - const pick = (items: readonly T[]): T => items[int(0, items.length - 1)]; - const chance = (p: number) => rnd() < p; - const shuffle = (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[] = []; - 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[] = shuffle( - createOptimizationPresetRules() as unknown as Record[], - ).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; diff --git a/src/app/services/frozen-engine/compare-engines.ts b/src/app/services/frozen-engine/compare-engines.ts new file mode 100644 index 0000000..7df2601 --- /dev/null +++ b/src/app/services/frozen-engine/compare-engines.ts @@ -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['result']): readonly PlanningObjective[] { + return result.explanation?.kind === 'PlanExplanation' + ? (result.explanation.placements[0]?.objectiveVector.objectives ?? []) + : []; +} + +const counts = new Map(); +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`, +); diff --git a/src/app/services/frozen-engine/leave-planner.core.calibration.d.mts b/src/app/services/frozen-engine/leave-planner.core.calibration.d.mts new file mode 100644 index 0000000..b3cffd4 --- /dev/null +++ b/src/app/services/frozen-engine/leave-planner.core.calibration.d.mts @@ -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; diff --git a/src/app/services/frozen-engine/leave-planner.core.calibration.mjs b/src/app/services/frozen-engine/leave-planner.core.calibration.mjs new file mode 100644 index 0000000..54ef602 --- /dev/null +++ b/src/app/services/frozen-engine/leave-planner.core.calibration.mjs @@ -0,0 +1,3222 @@ +// src/app/services/declared-non-working-days.ts +import Holidays from "date-holidays"; + +// src/app/services/planning-date.ts +function planningDateInParis(value) { + const parts = new Intl.DateTimeFormat("en-GB", { + timeZone: "Europe/Paris", + year: "numeric", + month: "2-digit", + day: "2-digit" + }).formatToParts(value); + const get = (type) => parts.find((part) => part.type === type)?.value ?? ""; + return `${get("year").padStart(4, "0")}-${get("month")}-${get("day")}`; +} +function addPlanningYears(key, years) { + const date = new Date(`${key}T00:00:00Z`); + const month = date.getUTCMonth(); + date.setUTCFullYear(date.getUTCFullYear() + years); + if (date.getUTCMonth() !== month) + date.setUTCDate(0); + return date.toISOString().slice(0, 10); +} +function isPlanningDate(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) + return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +// src/app/services/declared-non-working-days.ts +var holidaysByYear = new Map; +function frenchPublicHolidaysForYear(year) { + let holidays = holidaysByYear.get(year); + if (!holidays) { + holidays = new Set(new Holidays("FR", { languages: ["en"] }).getHolidays(year, "en").filter((holiday) => holiday.type === "public").map((holiday) => String(holiday.date).slice(0, 10))); + holidaysByYear.set(year, holidays); + } + return holidays; +} +function isWorkingPlanningDate(value) { + if (!isPlanningDate(value)) + return false; + const day = new Date(`${value}T00:00:00Z`).getUTCDay(); + return day !== 0 && day !== 6 && !frenchPublicHolidaysForYear(Number(value.slice(0, 4))).has(value); +} +function parseDeclaredDays(value) { + if (!Array.isArray(value)) + throw new Error("Declared non-working days must be a list."); + const dates = new Set; + return value.map((item) => { + if (typeof item !== "object" || item === null) + throw new Error("Each declared non-working day needs a date and leave type."); + const { date, type } = item; + if (!isWorkingPlanningDate(date) || dates.has(date)) + throw new Error("Declared dates must be unique working days outside French public holidays."); + if (type !== "cp" && type !== "rtt" && type !== "other") + throw new Error("Declared non-working days must have a CP, RTT, or Other type."); + dates.add(date); + return { date, type }; + }); +} + +// src/app/services/planning-objectives.ts +var OPTIMIZATION_CATALOG = [ + { + elements: "leave-balances", + measure: "unplaced-quantity", + conditions: ["at-risk"], + aggregations: ["sum", "count", "maximum"], + unit: "working-days" + }, + { + elements: "public-holiday-weeks", + measure: "public-holiday-week-value", + conditions: ["complete"], + aggregations: ["sum", "count", "maximum"], + unit: "calendar-days" + }, + { + elements: "time-off-periods", + measure: "non-working-day-gain", + conditions: [], + aggregations: ["sum", "count", "maximum"], + unit: "calendar-days" + }, + { + elements: "work-sequences", + measure: "work-sequence-length", + conditions: ["internal", "non-empty"], + aggregations: ["sum", "count", "maximum"], + unit: "working-days" + }, + { + elements: "work-sequences", + measure: "work-sequence-deficit", + conditions: ["internal", "non-empty"], + aggregations: ["sum", "count", "maximum"], + unit: "working-days" + }, + { + elements: "leave-placements", + measure: "placement-count", + conditions: ["supporting"], + aggregations: ["sum", "count", "maximum"], + unit: "count" + } +]; +function validateOptimizationConfiguration(value) { + const invalid = (message) => ({ + valid: false, + code: "invalid-optimization-configuration", + message + }); + const unsupported = (message) => ({ + valid: false, + code: "unsupported-optimization-configuration", + message + }); + if (!value || typeof value !== "object" || Array.isArray(value)) + return invalid("An optimization configuration must be an object."); + const config = value; + const fields = [ + "elements", + "conditions", + "measure", + "aggregation", + "direction", + "thresholdDays" + ]; + if (Object.keys(config).some((key) => !fields.includes(key))) + return unsupported("The optimization configuration contains an unsupported field."); + if (config["direction"] !== "minimize" && config["direction"] !== "maximize") + return invalid("The optimization direction must be minimize or maximize."); + if (!Array.isArray(config["conditions"]) || [...config["conditions"]].some((condition) => typeof condition !== "string") || new Set(config["conditions"]).size !== config["conditions"].length) + return invalid("Optimization conditions must be a list of distinct conditions."); + const row = OPTIMIZATION_CATALOG.find((item) => item.elements === config["elements"] && item.measure === config["measure"]); + if (!row) + return unsupported("The planning elements and measure are not compatible."); + const conditions = row.conditions; + if (config["conditions"].some((condition) => !conditions.includes(condition))) + return unsupported("A condition is not supported for these planning elements."); + const aggregations = row.aggregations; + if (typeof config["aggregation"] !== "string" || !aggregations.includes(config["aggregation"])) + return unsupported("The aggregation is not supported for this measure."); + if (config["measure"] === "work-sequence-deficit") { + if (!Number.isSafeInteger(config["thresholdDays"]) || Number(config["thresholdDays"]) <= 0) + return invalid("The deficit threshold must be a positive integer in working days."); + } else if ("thresholdDays" in config) + return unsupported("Only the work-sequence deficit accepts a working-day threshold."); + return { valid: true, configuration: value }; +} +var OPTIMIZATION_PRESETS = { + unplacedAtRisk: { + elements: "leave-balances", + conditions: ["at-risk"], + measure: "unplaced-quantity", + aggregation: "sum", + direction: "minimize" + }, + holidayWeekValue: { + elements: "public-holiday-weeks", + conditions: ["complete"], + measure: "public-holiday-week-value", + aggregation: "sum", + direction: "maximize" + }, + nonWorkingDayGain: { + elements: "time-off-periods", + conditions: [], + measure: "non-working-day-gain", + aggregation: "sum", + direction: "maximize" + }, + longestWorkSequence: { + elements: "work-sequences", + conditions: [], + measure: "work-sequence-length", + aggregation: "maximum", + direction: "minimize" + }, + shortSequenceDeficit: { + elements: "work-sequences", + conditions: ["internal", "non-empty"], + measure: "work-sequence-deficit", + thresholdDays: 5, + aggregation: "sum", + direction: "minimize" + }, + supportingPlacements: { + elements: "leave-placements", + conditions: ["supporting"], + measure: "placement-count", + aggregation: "count", + direction: "minimize" + } +}; +var nonWorkingDateCounts = new WeakMap; +function evaluateOptimization(configuration, observations) { + const validation = validateOptimizationConfiguration(configuration); + if (!validation.valid) + throw new Error(`${validation.code}: ${validation.message}`); + const selected = (condition) => configuration.conditions.includes(condition); + let values; + switch (configuration.elements) { + case "leave-balances": + values = observations.leaveBalances.filter((balance) => !selected("at-risk") || balance.atRisk).map((balance) => balance.unplacedQuantity); + break; + case "public-holiday-weeks": + values = observations.publicHolidayWeeks.filter((week) => !selected("complete") || week.complete).map((week) => week.value); + break; + case "time-off-periods": + values = observations.timeOffPeriods.map(({ nonWorkingDates }) => { + const cached = nonWorkingDateCounts.get(nonWorkingDates); + if (cached !== undefined) + return cached; + const count = new Set(nonWorkingDates).size; + if (Object.isFrozen(nonWorkingDates)) + nonWorkingDateCounts.set(nonWorkingDates, count); + return count; + }); + break; + case "work-sequences": + values = observations.workSequences.filter((sequence) => (!selected("internal") || sequence.internal) && (!selected("non-empty") || sequence.length > 0)).map((sequence) => configuration.measure === "work-sequence-deficit" ? sequence.length > 0 ? Math.max(0, configuration.thresholdDays - sequence.length) : 0 : sequence.length); + break; + case "leave-placements": + values = observations.leavePlacements.filter((placement) => !selected("supporting") || placement.supporting).map(() => 1); + } + switch (configuration.aggregation) { + case "count": + return values.length; + case "sum": + return values.reduce((total, value) => total + value, 0); + case "maximum": + return values.reduce((maximum, value) => Math.max(maximum, value), 0); + } +} +function compareObjectiveValues(direction, left, right) { + if (left === right) + return 0; + const difference = typeof left === "number" && typeof right === "number" ? left - right : left < right ? -1 : left > right ? 1 : 0; + return direction === "minimize" ? difference : -difference; +} +function firstDifferentObjective(left, right) { + return left.findIndex((objective, index) => objective.value !== right[index]?.value); +} +function compareObjectiveLists(left, right) { + const index = firstDifferentObjective(left, right); + return index < 0 ? 0 : compareObjectiveValues(left[index].direction, left[index].value, right[index].value); +} + +// src/app/services/leave-planner.core.ts +var obligationRequirementCache = new WeakMap; +var occurrenceCache = new WeakMap; +var calendarPatternCache = new WeakMap; +var preferencePatternCache = new WeakMap; +var declaredWindowCache = new WeakMap; +var calendarObservationCache = new WeakMap; +var calendarPeriodCache = new WeakMap; +function now() { + return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); +} +function observeCallback(context, callback) { + const started = now(); + try { + callback(); + } finally { + context.observerTime += now() - started; + } +} + +class DeadlineReached extends Error { +} + +class UserCancelled extends Error { +} +var DAY_MS = 86400000; +var OBLIGATION_SOLUTION_LIMIT = 4096; +var EXACT_OBLIGATION_BRANCH_LIMIT = 1e4; +var WINDOW_OBLIGATION_PREVIEW_LIMIT = 8; +var VALID_TYPES = new Set(["cp", "rtt", "other"]); +var VALID_SELECTORS = new Set(["cp", "rtt", "other", "any"]); +var VALID_MODES = new Set(["before", "after", "around", "auto"]); +function dateKey(value) { + if (typeof value === "string") { + const dateOnly = value.match(/^(\d{4}-\d{2}-\d{2})(?:$|\s|T)/)?.[1]; + if (dateOnly) + return isPlanningDate(dateOnly) ? dateOnly : null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : planningDateInParis(parsed); + } + if (value instanceof Date && !Number.isNaN(value.getTime())) + return planningDateInParis(value); + return null; +} +function runtimeRequest(value, calculationDate) { + const source = recordValue(value) ?? {}; + return { + calculationDate: calculationDate ?? (isPlanningDate(source.calculationDate) ? source.calculationDate : planningDateInParis(new Date)), + horizon: source.horizon, + balances: source.balances, + declaredDays: source.declaredDays, + rules: source.rules, + identities: source.identities, + labels: source.labels, + deadlineMs: source.deadlineMs, + progress: source.progress + }; +} +function recordValue(value) { + return typeof value === "object" && value !== null ? value : undefined; +} +function utcDate(key) { + return new Date(`${key}T00:00:00Z`); +} +function addDays(key, days) { + return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10); +} +function compareDate(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} +function compareText(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} +function dateRange(start, endExclusive) { + const result = []; + for (let date = start;compareDate(date, endExclusive) < 0; date = addDays(date, 1)) + result.push(date); + return result; +} +function defaultHorizon(start) { + return { start, end: addPlanningYears(start, 1) }; +} +function holidaySet(horizon) { + const first = Number(addDays(horizon.start, -400).slice(0, 4)); + const last = Number(addDays(horizon.end, 400).slice(0, 4)); + const result = new Set; + for (let year = first;year <= last; year++) { + for (const holiday of frenchPublicHolidaysForYear(year)) + result.add(holiday); + } + return result; +} +function isWeekend(key) { + const day = utcDate(key).getUTCDay(); + return day === 0 || day === 6; +} +function isWorking(key, holidays) { + return !isWeekend(key) && !holidays.has(key); +} +function holidayWeekCandidates(context) { + const candidates = new Map; + const inHorizon = (date) => date >= context.horizon.start && date < context.horizon.end; + for (const holiday of [...context.holidays].sort(compareDate)) { + const weekday = utcDate(holiday).getUTCDay(); + if (weekday < 1 || weekday > 5) + continue; + const start = addDays(holiday, 1 - weekday); + const weekdays = dateRange(start, addDays(start, 5)); + const dates = weekdays.filter((date) => isWorking(date, context.holidays)); + if (!dates.length || !dates.some(inHorizon) || !inHorizon(holiday) && !dates.some((date) => context.declaredDates.has(date)) || dates.some((date) => !inHorizon(date) && !context.declaredDates.has(date))) + continue; + const value = weekdays.reduce((sum, date, index) => sum + (context.holidays.has(date) ? Math.min(index + 1, 5 - index) : 0), 0); + candidates.set(start, { start, dates, value }); + } + return [...candidates.values()].sort((a, b) => b.value - a.value || compareDate(a.start, b.start)); +} +function reason(code, message, ruleIds) { + return ruleIds?.length ? { code, message, ruleIds } : { code, message }; +} +function cloneState(state, placements) { + return { + balances: state.balances.map((balance) => ({ ...balance })), + placements: placements ?? new Map([...state.placements.entries()].map(([date, placement]) => [ + date, + { + ...placement, + ruleIds: new Set(placement.ruleIds) + } + ])), + earnedActivations: state.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates] + })) + }; +} +function planningStateSnapshot(state, context) { + return { + calculationDate: context.request.calculationDate, + horizon: context.horizon, + ...context.declaredDays.length ? { declaredDays: context.declaredDays } : {}, + balances: state.balances.map((balance) => ({ ...balance })), + placements: [...state.placements.values()].sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)).map((placement) => ({ + ...placement, + ruleIds: [...placement.ruleIds].sort() + })), + earnedActivations: state.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates] + })) + }; +} +function normalizeType(value) { + const normalized = String(value ?? "").toLowerCase(); + return VALID_TYPES.has(normalized) ? normalized : null; +} +function normalizeSelector(value) { + const normalized = String(value ?? "").toLowerCase(); + return VALID_SELECTORS.has(normalized) ? normalized : null; +} +function normalizeForce(value) { + if (value === undefined || value === "") + return "preference"; + const normalized = String(value).toLowerCase(); + if (normalized === "obligation" || normalized === "preference") + return normalized; + return null; +} +function normalizeRuleType(value) { + const normalized = String(value ?? "").toLowerCase(); + return normalized === "time_window" || normalized === "if_then" || normalized === "optimization" ? normalized : null; +} +function normalizeMode(value) { + const normalized = String(value ?? "").toLowerCase(); + return VALID_MODES.has(normalized) ? normalized : null; +} +function compareDateLists(a, b) { + for (let index = 0;index < Math.min(a.length, b.length); index++) { + const compared = compareDate(a[index], b[index]); + if (compared) + return compared; + } + return a.length - b.length; +} +function asInteger(value) { + return typeof value === "number" && Number.isInteger(value) ? value : null; +} +function normalizeBalances(request, horizon) { + const rawInput = request.balances; + const reasons = []; + if (!Array.isArray(rawInput)) { + return { + balances: [], + reasons: [reason("balances", "Leave balances must be an array.")] + }; + } + const raw = rawInput; + const ids = new Set; + const balances = []; + for (const input of raw) { + const value = recordValue(input); + const id = typeof value?.id === "string" ? value.id : ""; + const type = normalizeType(value?.type); + const quantity = asInteger(value?.quantity); + const rawAvailableFrom = value?.availableFrom; + const availableFrom = dateKey(rawAvailableFrom) ?? horizon.start; + const expiryDate = dateKey(value?.expiryDate); + if (!id) + reasons.push(reason("balance-id", "Each leave balance needs a stable identity.")); + else if (ids.has(id)) + reasons.push(reason("duplicate-balance-id", `Leave balance id "${id}" is duplicated.`)); + else + ids.add(id); + if (!type) + reasons.push(reason("balance-type", `Leave balance "${id}" has an invalid leave type.`)); + if (quantity === null || quantity < 0) { + reasons.push(reason("balance-quantity", `Leave balance "${id}" needs a non-negative integer quantity.`)); + } + if (!expiryDate) + reasons.push(reason("balance-expiry", `Leave balance "${id}" needs a valid expiry date.`)); + if (rawAvailableFrom !== undefined && !dateKey(rawAvailableFrom)) + reasons.push(reason("balance-availability", `Leave balance "${id}" needs a valid availability date.`)); + if (expiryDate && compareDate(availableFrom, expiryDate) > 0 && rawAvailableFrom !== undefined) { + reasons.push(reason("balance-dates", `Leave balance "${id}" must be available on or before expiry.`)); + } + if (type && quantity !== null && quantity >= 0 && expiryDate) { + balances.push({ + id, + type, + quantity, + availableFrom, + expiryDate, + earned: false + }); + } + } + return { balances, reasons }; +} +function normalizeRules(request) { + const rawInput = request.rules; + const reasons = []; + if (!Array.isArray(rawInput)) { + return { rules: [], reasons: [reason("rules", "Planning rules must be an array.")] }; + } + const raw = rawInput; + const ids = new Set; + const rules = []; + for (const input of raw) { + const value = recordValue(input); + if (value?.enabled === false) + continue; + const id = typeof value?.id === "string" ? value.id : ""; + const type = normalizeRuleType(value?.type); + const forceValue = normalizeForce(value?.force); + const force = forceValue ?? "preference"; + if (!id) + reasons.push(reason("rule-id", "Each planning rule needs a stable identity.")); + else if (ids.has(id)) + reasons.push(reason("duplicate-rule-id", `Planning rule id "${id}" is duplicated.`)); + else + ids.add(id); + if (!forceValue) + reasons.push(reason("rule-force", `Planning rule "${id}" has an invalid force.`)); + if (!type) { + reasons.push(reason("rule-type", `Planning rule "${id}" has an unsupported type.`)); + continue; + } + if (type === "optimization") { + const validated = validateOptimizationConfiguration(value?.configuration); + if (forceValue !== "preference") + reasons.push(reason("rule-force", "Optimization rules support preference mode only.", [id])); + if (!validated.valid) { + reasons.push(reason(validated.code, validated.message, [id])); + continue; + } + rules.push({ + id, + name: typeof value?.name === "string" ? value.name : id, + type, + enabled: true, + force: "preference", + configuration: structuredClone(validated.configuration) + }); + continue; + } + const placeDays = asInteger(value?.placeDays); + const placeType = normalizeSelector(value?.placeType); + if (placeDays === null || placeDays < 0) { + reasons.push(reason("rule-quantity", `Planning rule "${id}" needs a non-negative integer quantity.`)); + continue; + } + if (!placeType) + reasons.push(reason("rule-selector", `Planning rule "${id}" has an invalid leave selector.`)); + const base = { + id, + name: typeof value?.name === "string" ? value.name : id, + type, + enabled: value?.enabled !== false, + force, + placeDays, + placeType: placeType ?? "any" + }; + if (type === "time_window") { + const startDate = dateKey(value?.startDate); + const endDate = dateKey(value?.endDate); + if (!startDate || !endDate || compareDate(startDate, endDate) > 0) { + reasons.push(reason("rule-window", `TIME_WINDOW rule "${id}" needs an inclusive, ordered date window.`)); + continue; + } + rules.push({ ...base, startDate, endDate, type: "time_window" }); + continue; + } + const dayOfWeek = asInteger(value?.dayOfWeek); + const mode = normalizeMode(value?.placement); + const rewardDays = asInteger(value?.rewardDays); + const rawRewardType = value?.rewardType; + const rewardType = normalizeType(rawRewardType); + if (dayOfWeek === null || dayOfWeek < 0 || dayOfWeek > 6) { + reasons.push(reason("rule-weekday", `IF_THEN rule "${id}" needs a weekday from 0 to 6.`)); + } + if (!mode) + reasons.push(reason("rule-mode", `IF_THEN rule "${id}" has an unsupported placement mode.`)); + if (rewardDays === null || rewardDays < 0) { + reasons.push(reason("earned-quantity", `IF_THEN rule "${id}" needs a non-negative earned quantity.`)); + } + if ((rewardDays ?? 0) > 0 && !rewardType) { + reasons.push(reason("earned-type", `IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`)); + } else if (rawRewardType !== undefined && !rewardType) { + reasons.push(reason("earned-type", `IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`)); + } + if (placeType && mode && dayOfWeek !== null && rewardDays !== null && (rewardDays === 0 ? rawRewardType === undefined || rewardType : rewardType)) { + rules.push({ + ...base, + type: "if_then", + dayOfWeek, + placement: mode, + rewardDays, + rewardType: rewardType ?? undefined + }); + } + } + return { rules, reasons }; +} +function normalizeRequest(request) { + const source = runtimeRequest(request); + const rawHorizon = recordValue(source.horizon); + const fallbackHorizon = defaultHorizon(source.calculationDate); + const start = dateKey(rawHorizon?.start) ?? fallbackHorizon.start; + const end = dateKey(rawHorizon?.end) ?? fallbackHorizon.end; + const reasons = []; + if (!rawHorizon) { + reasons.push(reason("horizon", "The planning horizon is required.")); + } else if (!dateKey(rawHorizon.start)) { + reasons.push(reason("horizon-start", "The planning horizon start must be a valid calendar date.")); + } + if (rawHorizon && !dateKey(rawHorizon.end)) { + reasons.push(reason("horizon-end", "The planning horizon end must be a valid calendar date.")); + } + if (rawHorizon && compareDate(start, end) >= 0) { + reasons.push(reason("horizon-order", "The planning horizon must have start before end.")); + } + const configuredDeadline = source.deadlineMs; + if (configuredDeadline !== undefined && (typeof configuredDeadline !== "number" || !Number.isFinite(configuredDeadline) || configuredDeadline < 0)) { + reasons.push(reason("deadline", "The planning deadline must be a finite non-negative number.")); + } + let declaredDays = []; + try { + declaredDays = parseDeclaredDays(source.declaredDays === undefined ? [] : source.declaredDays); + } catch (error) { + reasons.push(reason("declared-days", error instanceof Error ? error.message : String(error))); + } + const balanceResult = normalizeBalances(source, { start, end }); + const ruleResult = normalizeRules(source); + reasons.push(...balanceResult.reasons, ...ruleResult.reasons); + return { + horizon: reasons.some((item) => item.code === "horizon" || item.code.startsWith("horizon-")) ? undefined : { start, end }, + balances: balanceResult.balances, + declaredDays, + rules: ruleResult.rules, + reasons + }; +} +function invalidInputFor(request) { + const normalized = normalizeRequest(request); + if (!normalized.reasons.length) + return; + return { + kind: "InvalidInput", + reasons: normalized.reasons + }; +} +function futurePreferenceProtection(rules, startIndex, context) { + const protectedTypes = new Map; + context.preferenceDemandByDate = new Map; + context.anyPreferenceDemandByDate = new Map; + const futureRules = rules.slice(startIndex); + for (const [offset, rule] of futureRules.entries()) { + if (!rule.enabled || rule.force !== "preference" || rule.type === "optimization") + continue; + if (rule.type === "time_window" && declaredWindowDays(rule, context).length >= rule.placeDays) + continue; + const priorityWeight = futureRules.length - offset; + const dates = rule.type === "time_window" ? context.workingDays.filter((date) => !context.declaredDates.has(date) && compareDate(date, rule.startDate) >= 0 && compareDate(date, rule.endDate) <= 0) : occurrences(rule, context).flatMap((occurrence) => { + const patterns = patternsFor(rule, occurrence, context); + return patterns.some((pattern) => pattern.every((date) => declaredPatternDay(date, rule.placeType, context))) ? [] : patterns.flat().filter((date) => !context.declaredDates.has(date)); + }); + for (const date of dates) { + context.preferenceDemandByDate.set(date, (context.preferenceDemandByDate.get(date) ?? 0) + priorityWeight); + if (rule.placeType === "any") { + context.anyPreferenceDemandByDate.set(date, (context.anyPreferenceDemandByDate.get(date) ?? 0) + priorityWeight); + continue; + } + const types = protectedTypes.get(date) ?? new Set; + types.add(rule.placeType); + protectedTypes.set(date, types); + } + } + return protectedTypes; +} +function occurrences(rule, context) { + if (rule.type !== "if_then") + return []; + let cached = occurrenceCache.get(context); + if (!cached) { + cached = new Map; + occurrenceCache.set(context, cached); + } + let dates = cached.get(rule.id); + if (!dates) { + dates = [...context.holidays].filter((date) => date >= context.horizon.start && date < context.horizon.end && utcDate(date).getUTCDay() === rule.dayOfWeek).sort(compareDate); + cached.set(rule.id, dates); + } + return dates; +} +function workingNeighbors(date, direction, count, context) { + const result = []; + for (let current = addDays(date, direction);result.length < count; current = addDays(current, direction)) { + context.check?.(); + if (isWorking(current, context.holidays)) + result.push(current); + if (compareDate(current, addDays(context.horizon.start, -400)) < 0 || compareDate(current, addDays(context.horizon.end, 400)) > 0) + break; + } + return result; +} +function patternsFor(rule, holiday, context) { + let cached = calendarPatternCache.get(context); + if (!cached) { + cached = new Map; + calendarPatternCache.set(context, cached); + } + const key = `${rule.id}:${holiday}`; + const existing = cached.get(key); + if (existing) + return existing; + const count = rule.placeDays; + const make = (before, after) => [ + ...workingNeighbors(holiday, -1, before, context), + ...workingNeighbors(holiday, 1, after, context).reverse() + ].sort(compareDate); + const patterns = []; + if (rule.placement === "before") + patterns.push(make(count, 0)); + else if (rule.placement === "after") + patterns.push(make(0, count)); + else if (rule.placement === "around") { + for (let before = 1;before < count; before++) + patterns.push(make(before, count - before)); + } else { + for (let before = 0;before <= count; before++) + patterns.push(make(before, count - before)); + } + const eligible = patterns.filter((pattern) => pattern.length === count && pattern.every((date) => compareDate(date, context.horizon.start) >= 0 && compareDate(date, context.horizon.end) < 0)).sort(compareDateLists); + cached.set(key, eligible); + return eligible; +} +function buildRequirements(rules, context, force) { + if (force === "obligation" && rules === context.rules) { + const cached = obligationRequirementCache.get(context); + if (cached) + return cached; + } + const requirements = []; + let index = 0; + for (const rule of rules) { + if (!rule.enabled || rule.force !== force || rule.type === "optimization") + continue; + if (rule.type === "time_window") { + const start = compareDate(rule.startDate, context.horizon.start) < 0 ? context.horizon.start : rule.startDate; + const end = compareDate(rule.endDate, context.horizon.end) >= 0 ? addDays(context.horizon.end, -1) : rule.endDate; + const candidates = compareDate(start, end) <= 0 ? context.workingDays.filter((date) => !context.declaredDates.has(date) && compareDate(date, start) >= 0 && compareDate(date, end) <= 0) : []; + const orderedCandidates = candidates.sort((a, b) => preferenceDateConflictScore(rule.placeType, a, context) - preferenceDateConflictScore(rule.placeType, b, context) || compareDate(a, b)); + requirements.push({ + rule, + requiredPlacements: rule.placeDays - declaredWindowDays(rule, context).length, + anchor: start, + index: index++, + candidates: orderedCandidates + }); + } else { + for (const occurrence of occurrences(rule, context)) { + const patterns = patternsFor(rule, occurrence, context).sort((a, b) => patternConflictScore(rule.placeType, a, context) - patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b)); + requirements.push({ + rule, + requiredPlacements: Math.min(rule.placeDays, ...patterns.map((pattern) => pattern.filter((date) => !declaredPatternDay(date, rule.placeType, context)).length)), + anchor: occurrence, + occurrenceDate: occurrence, + index: index++, + patterns + }); + } + } + } + const ordered = requirements.sort(compareRequirements); + if (force === "obligation" && rules === context.rules) + obligationRequirementCache.set(context, ordered); + return ordered; +} +function compareRequirements(a, b) { + return Number(a.rule.type !== "if_then") - Number(b.rule.type !== "if_then") || compareDate(a.anchor, b.anchor) || a.index - b.index; +} +function obligationBranchEstimate(requirements) { + let estimate = 1; + for (const requirement of requirements) { + let branches = requirement.patterns?.length ?? 1; + if (!requirement.patterns) { + const candidates = requirement.candidates?.length ?? 0; + const needed = requirement.requiredPlacements; + branches = 1; + for (let index = 1;index <= needed; index++) { + if (index > candidates) { + branches = 1; + break; + } + branches = Math.min(EXACT_OBLIGATION_BRANCH_LIMIT + 1, branches * (candidates - needed + index) / index); + } + } + estimate = Math.min(EXACT_OBLIGATION_BRANCH_LIMIT + 1, estimate * branches); + if (estimate > EXACT_OBLIGATION_BRANCH_LIMIT) + return estimate; + } + return estimate; +} +function selectorMatches(selector, type) { + return selector === "any" || selector === type; +} +function declaredWindowDays(rule, context) { + if (rule.type !== "time_window" || !context.declaredDays.length) + return []; + let byRule = declaredWindowCache.get(context); + if (!byRule) { + byRule = new Map; + declaredWindowCache.set(context, byRule); + } + let days = byRule.get(rule.id); + if (!days) { + days = context.declaredDays.filter((day) => selectorMatches(rule.placeType, day.type) && day.date >= context.horizon.start && day.date < context.horizon.end && day.date >= rule.startDate && day.date <= rule.endDate).sort((a, b) => compareDate(a.date, b.date)).slice(0, rule.placeDays); + byRule.set(rule.id, days); + } + return days; +} +function declaredPatternDay(date, selector, context) { + if (!context.declaredDates.has(date)) + return; + return context.declaredDays.find((day) => day.date === date && selectorMatches(selector, day.type)); +} +function patternDateSatisfied(date, rule, state, context) { + return Boolean(declaredPatternDay(date, rule.placeType, context) || state.placements.has(date) && selectorMatches(rule.placeType, state.placements.get(date).type)); +} +function selectedOccurrencePattern(rule, occurrence, state, context) { + const activation = state.earnedActivations.find((item) => item.ruleId === rule.id && item.occurrence === occurrence); + return activation?.dates.every((date) => patternDateSatisfied(date, rule, state, context)) ? activation.dates : patternsFor(rule, occurrence, context).find((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, state, context))); +} +function assignDateVariants(state, date, selector, ruleId, context) { + context.check(); + if (context.declaredDates.has(date)) + return []; + const existing = state.placements.get(date); + if (existing) { + if (!selectorMatches(selector, existing.type)) { + return []; + } + if (ruleId) { + const rule = context.rules.find((item) => item.id === ruleId); + if (rule?.type === "time_window" && (compareDate(date, rule.startDate) < 0 || compareDate(date, rule.endDate) > 0)) { + return []; + } + } + const result = cloneState(state); + if (ruleId) + result.placements.get(date).ruleIds.add(ruleId); + return [result]; + } + const balances = state.balances.filter((balance) => balance.remaining > 0 && selectorMatches(selector, balance.type) && compareDate(balance.availableFrom, date) <= 0 && compareDate(date, balance.expiryDate) <= 0).sort((a, b) => { + if (selector === "any") { + const sharedTypes = context.sharedTypesByDate.get(date) ?? new Set; + if (sharedTypes.has(a.type) !== sharedTypes.has(b.type)) + return sharedTypes.has(a.type) ? -1 : 1; + const protectedTypes = context.protectedTypesByDate.get(date) ?? new Set; + if (protectedTypes.size && protectedTypes.has(a.type) !== protectedTypes.has(b.type)) + return protectedTypes.has(a.type) ? -1 : 1; + if (context.reservedTypes.has(a.type) !== context.reservedTypes.has(b.type)) { + return context.reservedTypes.has(a.type) ? 1 : -1; + } + } + 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--; + result.placements.set(date, { + date, + balanceId: target.id, + type: target.type, + ruleIds: ruleId ? new Set([ruleId]) : new Set, + supporting: false + }); + return result; + }); +} +function assignDateToBalance(state, date, balanceId, context, ruleIds = new Set) { + context.check(); + if (context.declaredDates.has(date)) + return null; + if (state.placements.has(date)) { + return null; + } + const balance = state.balances.find((item) => item.id === balanceId); + if (!balance || balance.remaining <= 0 || compareDate(balance.availableFrom, date) > 0 || compareDate(date, balance.expiryDate) > 0) { + return null; + } + if (!mandatoryRelocationDate(date, balance, ruleIds, context)) + return null; + const result = cloneState(state, new Map(state.placements)); + const target = result.balances.find((item) => item.id === balanceId); + target.remaining--; + result.placements.set(date, { + date, + balanceId, + type: balance.type, + ruleIds: new Set(ruleIds), + supporting: false + }); + return result; +} +function addEarnedBalance(state, rule, occurrence, dates, context) { + if (!rule.rewardDays || !rule.rewardType) + return state; + const result = cloneState(state); + const id = `earned:${rule.id}:${occurrence}`; + const activation = { id, ruleId: rule.id, occurrence, dates: [...dates] }; + if (!result.earnedActivations.some((item) => item.id === id)) + result.earnedActivations.push(activation); + const bounds = earnedPeriodBounds(state, activation, context); + if (bounds.availableFrom <= context.request.calculationDate) + return result; + if (!result.balances.some((balance) => balance.id === id)) { + result.balances.push({ + id, + type: rule.rewardType, + quantity: rule.rewardDays, + remaining: rule.rewardDays, + ...bounds, + earned: true, + sourceRuleId: rule.id + }); + } + return result; +} +function earnedPeriodBounds(state, activation, context) { + const allDates = [...activation.dates, activation.occurrence].sort(compareDate); + let start = allDates[0] ?? activation.occurrence; + let end = allDates[allDates.length - 1] ?? activation.occurrence; + const mergedPeriod = periodData(state, context).find((period) => activation.dates.some((date) => period.placementDates.includes(date) || period.declaredDates?.includes(date))); + if (mergedPeriod) { + start = mergedPeriod.start; + end = mergedPeriod.end; + } + while (!isWorking(addDays(start, -1), context.holidays)) + start = addDays(start, -1); + while (!isWorking(addDays(end, 1), context.holidays)) + end = addDays(end, 1); + const availableFrom = addDays(end, 1); + return { availableFrom, expiryDate: addPlanningYears(availableFrom, 1) }; +} +function finalizeEarnedState(state, context) { + let reconciled = state; + for (const rule of context.rules) { + if (rule.type !== "if_then" || !rule.rewardDays) + continue; + for (const occurrence of occurrences(rule, context)) { + if (reconciled.earnedActivations.some((activation) => activation.ruleId === rule.id && activation.occurrence === occurrence)) + continue; + const pattern = selectedOccurrencePattern(rule, occurrence, reconciled, context); + if (pattern) + reconciled = addEarnedBalance(reconciled, rule, occurrence, pattern, context); + } + } + if (!reconciled.earnedActivations.length) + return reconciled; + let result = cloneState(reconciled); + for (const activation of result.earnedActivations) { + context.check(); + const bounds = earnedPeriodBounds(result, activation, context); + if (bounds.availableFrom <= context.request.calculationDate) { + if ([...result.placements.values()].some((placement) => placement.balanceId === activation.id)) + return null; + result.balances = result.balances.filter((balance2) => balance2.id !== activation.id); + continue; + } + let balance = result.balances.find((item) => item.id === activation.id); + if (!balance) { + const rule = context.rules.find((rule2) => rule2.type === "if_then" && rule2.id === activation.ruleId); + if (!rule) + return null; + result = addEarnedBalance(result, rule, activation.occurrence, activation.dates, context); + balance = result.balances.find((item) => item.id === activation.id); + if (!balance) + continue; + } + balance.availableFrom = bounds.availableFrom; + balance.expiryDate = bounds.expiryDate; + for (const placement of result.placements.values()) { + if (placement.balanceId === activation.id && (compareDate(placement.date, balance.availableFrom) < 0 || compareDate(placement.date, balance.expiryDate) > 0)) + return null; + } + } + return result; +} +function* assignDatesVariants(state, dates, rule, context) { + if (!dates.length) + return [state]; + const [date, ...rest] = dates; + if (!isWorking(date, context.holidays) || compareDate(date, context.horizon.start) < 0 || compareDate(date, context.horizon.end) >= 0) + return []; + if (context.declaredDates.has(date)) { + if (declaredPatternDay(date, rule.placeType, context)) + return yield* assignDatesVariants(state, rest, rule, context); + return []; + } + const result = []; + for (const next of assignDateVariants(state, date, rule.placeType, rule.id, context)) { + yield; + result.push(...yield* assignDatesVariants(next, rest, rule, context)); + } + return result; +} +function* satisfyObligations(requirements, state, context, index = 0, solutions, solutionLimit = OBLIGATION_SOLUTION_LIMIT, chooseOrder = true) { + yield; + context.check(); + if (index >= requirements.length) { + const finalized = finalizeRuleCandidate(state, context, state); + if (!finalized) + return null; + if (solutions) + solutions.push(finalized); + context.retain?.(finalized); + return solutions ? null : finalized; + } + const requirement = requirements[index]; + const extensionDates = chooseOrder && context.horizon.end > context.request.calculationDate ? state.earnedActivations.map((activation) => state.balances.find((balance) => balance.id === activation.id)?.availableFrom ?? earnedPeriodBounds(state, activation, context).availableFrom) : []; + const extensions = extensionDates.length ? requirements.slice(index + 1).flatMap((candidate, offset) => (candidate.patterns ? candidate.patterns.some((pattern) => pattern.some((date) => extensionDates.includes(date))) : candidate.requiredPlacements > 0 && candidate.candidates?.some((date) => extensionDates.includes(date))) ? [index + offset + 1] : []) : []; + if (extensions.length) { + for (const next of [index, ...extensions]) { + yield; + const ordered = next === index ? requirements : [ + ...requirements.slice(0, index), + requirements[next], + ...requirements.slice(index, next), + ...requirements.slice(next + 1) + ]; + const complete = yield* satisfyObligations(ordered, state, context, index, solutions, solutionLimit, false); + if (complete && !solutions) + return complete; + if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) + break; + } + return null; + } + if (requirement.optional) { + const complete = yield* satisfyObligations(requirements, state, context, index + 1, solutions, solutionLimit); + if (complete && !solutions) + return complete; + } + if (requirement.patterns) { + for (const pattern of requirement.patterns) { + yield; + for (const assigned of yield* assignDatesVariants(state, pattern, requirement.rule, context)) { + yield; + const candidate = finalizeEarnedState(addEarnedBalance(assigned, requirement.rule, requirement.occurrenceDate, pattern, context), context); + if (!candidate) + continue; + const complete = yield* satisfyObligations(requirements, candidate, context, index + 1, solutions, solutionLimit); + if (complete && !solutions) + return complete; + if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) { + return null; + } + } + } + return null; + } + const candidates = requirement.candidates ?? []; + const needed = requirement.requiredPlacements; + const choose = function* (position, picked, current) { + yield; + context.check(); + if (picked.length === needed) { + const finalized = finalizeEarnedState(current, context); + if (!finalized) + return null; + const complete = yield* satisfyObligations(requirements, finalized, context, index + 1, solutions, solutionLimit); + if (complete && !solutions) + return complete; + return null; + } + if (candidates.length - position < needed - picked.length) { + return null; + } + for (let i = position;i < candidates.length; i++) { + yield; + for (const next of assignDateVariants(current, candidates[i], requirement.rule.placeType, requirement.rule.id, context)) { + yield; + const complete = yield* choose(i + 1, [...picked, candidates[i]], next); + if (complete && !solutions) + return complete; + if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) { + return null; + } + } + } + return null; + }; + return yield* choose(0, [], state); +} +function releasePlacement(state, date) { + const result = { + balances: state.balances.map((balance) => ({ ...balance })), + placements: new Map(state.placements), + earnedActivations: state.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates] + })) + }; + const placement = result.placements.get(date); + result.placements.delete(date); + result.balances.find((balance) => balance.id === placement.balanceId).remaining++; + return result; +} +function reconcileOptimizationProvenance(state, context, before, objectives = activeObjectiveVector(state, context)) { + const optimizations = objectives.filter(({ id }) => id.startsWith("optimization:")); + if (!optimizations.length) + return state; + let result = state; + let observations; + for (const placement of state.placements.values()) { + const previous = before.placements.get(placement.date); + const creditedIds = new Set([...placement.ruleIds, ...previous?.ruleIds ?? []]); + const credits = optimizations.filter(({ id }) => creditedIds.has(id.slice("optimization:".length))); + if (!credits.length) + continue; + const withoutState = releasePlacement(state, placement.date); + const withoutPlacement = activeObjectiveVector(withoutState, context); + const ruleIds = new Set([...placement.ruleIds].filter((id) => !credits.some((objective) => objective.id === `optimization:${id}`))); + for (const objective of credits) { + const without = withoutPlacement.find(({ id: id2 }) => id2 === objective.id); + const id = objective.id.slice("optimization:".length); + const rule = context.rules.find((rule2) => rule2.id === id && rule2.type === "optimization"); + const compared = compareObjectiveValues(objective.direction, objective.value, without.value); + let contributes = compared < 0; + if (compared === 0 && rule.configuration.aggregation === "maximum") { + observations ??= optimizationObservations(state, context); + const withoutObservations = optimizationObservations(withoutState, context); + const summed = { ...rule.configuration, aggregation: "sum" }; + const summedComparison = compareObjectiveValues(objective.direction, evaluateOptimization(summed, observations), evaluateOptimization(summed, withoutObservations)); + contributes = summedComparison < 0 || summedComparison === 0 && rule.configuration.elements === "time-off-periods" && objective.direction === "maximize" && observations.timeOffPeriods.length < withoutObservations.timeOffPeriods.length; + } + if (contributes) + ruleIds.add(id); + } + if (ruleIds.size === placement.ruleIds.size && [...ruleIds].every((id) => placement.ruleIds.has(id))) + continue; + if (result === state) + result = cloneState(state); + result.placements.get(placement.date).ruleIds = ruleIds; + } + return result; +} +function supportingCalendarBenefit(before, after, context) { + 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); +} +function mandatoryRelocationDate(date, balance, ruleIds, context) { + return [...ruleIds].every((id) => { + const rule = context.rules.find((item) => item.id === id); + if (rule?.force !== "obligation") + return true; + if (!selectorMatches(rule.placeType, balance.type)) + return false; + return rule.type === "time_window" ? date >= rule.startDate && date <= rule.endDate : occurrences(rule, context).some((occurrence) => patternsFor(rule, occurrence, context).some((pattern) => pattern.includes(date))); + }); +} +function relocationCanReachFree(state, context, balanceId, ruleIds, visited, failed) { + const key = JSON.stringify([balanceId, [...ruleIds].sort(), [...visited].sort()]); + if (failed.has(key)) + return false; + const queue = [{ balanceId, ruleIds }]; + const seen = new Set; + for (let index = 0;index < queue.length; index++) { + const entry = queue[index]; + const identity = JSON.stringify([entry.balanceId, [...entry.ruleIds].sort()]); + if (seen.has(identity)) + continue; + seen.add(identity); + const balance = state.balances.find((item) => item.id === entry.balanceId); + for (const date of context.workingDays) { + context.check(); + if (visited.has(date) || context.declaredDates.has(date) || date < balance.availableFrom || date > balance.expiryDate || !mandatoryRelocationDate(date, balance, entry.ruleIds, context)) + continue; + const placement = state.placements.get(date); + if (!placement) + return true; + queue.push({ balanceId: placement.balanceId, ruleIds: placement.ruleIds }); + } + } + failed.add(key); + return false; +} +function* relocateForAddition(state, context, date, balanceId, ruleIds, supporting, accept, visited = new Set, failedReachability = new Set, failedSearches = new Set) { + yield; + context.check(); + if (visited.has(date) || context.declaredDates.has(date)) + return false; + const existing = state.placements.get(date); + if (existing?.balanceId === balanceId && existing.supporting === supporting && existing.ruleIds.size === ruleIds.size && [...existing.ruleIds].every((id) => ruleIds.has(id))) + return false; + const key = JSON.stringify([ + date, + balanceId, + [...ruleIds].sort(), + supporting, + [...visited].sort(), + state.balances.map((balance) => [balance.id, balance.remaining]), + [...state.placements.values()].sort((a, b) => compareDate(a.date, b.date)).map((placement) => [ + placement.date, + placement.balanceId, + [...placement.ruleIds].sort(), + placement.supporting + ]) + ]); + if (failedSearches.has(key)) { + return false; + } + const assigned = assignDateToBalance(existing ? releasePlacement(state, date) : state, date, balanceId, context, ruleIds); + if (!assigned) + return false; + assigned.placements.get(date).supporting = supporting; + if (!existing) { + const accepted = accept(assigned); + if (!accepted) + failedSearches.add(key); + return accepted; + } + const displaced = assigned.balances.find((balance) => balance.id === existing.balanceId); + const nextVisited = new Set([...visited, date]); + if (!relocationCanReachFree(assigned, context, existing.balanceId, existing.ruleIds, nextVisited, failedReachability)) + return false; + for (const destination of context.workingDays) { + yield; + if (destination < displaced.availableFrom || destination > displaced.expiryDate) + continue; + if (!mandatoryRelocationDate(destination, displaced, existing.ruleIds, context)) + continue; + if (yield* relocateForAddition(assigned, context, destination, existing.balanceId, existing.ruleIds, existing.supporting, accept, nextVisited, failedReachability, failedSearches)) + return true; + } + failedSearches.add(key); + return false; +} +function finalizeRuleCandidate(state, context, before) { + let result = state; + const windowCredits = new Map; + const patternCredits = new Map; + for (const placement of state.placements.values()) { + let lostCalendarRequest = false; + let hasCalendarRequest = false; + for (const id of placement.ruleIds) { + const rule = context.rules.find((item) => item.id === id); + if (rule?.type === "optimization") + continue; + const valid = counterfactualRuleValid(placement.date, placement.type, [id], context, state); + const credited = windowCredits.get(id) ?? 0; + let withinRequest = true; + if (rule?.type === "time_window") + withinRequest = credited < Math.max(0, rule.placeDays - declaredWindowDays(rule, context).length); + else if (rule?.type === "if_then") { + if (!patternCredits.has(id)) + patternCredits.set(id, new Set(occurrences(rule, context).flatMap((occurrence) => selectedOccurrencePattern(rule, occurrence, state, context) ?? []))); + withinRequest = patternCredits.get(id).has(placement.date); + } + if (valid && withinRequest) { + hasCalendarRequest = true; + if (rule?.type === "time_window") + windowCredits.set(id, credited + 1); + continue; + } + if (!valid && rule?.force === "obligation") + return null; + if (result === state) + result = cloneState(state); + result.placements.get(placement.date).ruleIds.delete(id); + lostCalendarRequest = true; + } + const current = result.placements.get(placement.date); + const previous = before.placements.get(placement.date); + let supporting = current.supporting; + if (hasCalendarRequest) + supporting = false; + else if (lostCalendarRequest || previous && (previous.balanceId !== placement.balanceId || previous.supporting !== current.supporting)) { + const balance = result.balances.find((item) => item.id === placement.balanceId); + supporting = !isAtRisk(balance, context) && (current.supporting || previous?.balanceId === placement.balanceId && previous.supporting || supportingCalendarBenefit(optimizationObservations(releasePlacement(result, placement.date), context), result, context)); + } + if (supporting !== current.supporting) { + if (result === state) + result = cloneState(state); + result.placements.get(placement.date).supporting = supporting; + } + } + for (const activation of state.earnedActivations) { + const rule = context.rules.find((item) => item.type === "if_then" && item.id === activation.ruleId); + if (activation.dates.every((date) => patternDateSatisfied(date, rule, state, context))) + continue; + if ([...state.placements.values()].some((placement) => placement.balanceId === activation.id)) + return null; + result = cloneState(result); + result.earnedActivations = result.earnedActivations.filter((item) => item.id !== activation.id); + result.balances = result.balances.filter((balance) => balance.id !== activation.id); + } + const finalized = finalizeEarnedState(result, context); + return finalized && !unsatisfiedObligationCount(finalized, context) ? finalized : null; +} +function typedCalendarDates(context) { + const typedDates = new Set; + for (const rule of context.rules) { + if (rule.type === "optimization" || rule.placeType === "any") + continue; + const dates = rule.type === "time_window" ? context.workingDays.filter((date) => rule.startDate <= date && date <= rule.endDate) : occurrences(rule, context).flatMap((occurrence) => patternsFor(rule, occurrence, context).flat()); + for (const date of dates) + typedDates.add(date); + } + return typedDates; +} +function* calendarPlacementCandidates(state, targetDates, ruleId, context, accept) { + const missing = targetDates.filter((date) => !state.placements.has(date) && !context.declaredDates.has(date)); + if (!missing.length) + return false; + const before = optimizationObservations(state, context); + const typedDates = typedCalendarDates(context); + const failed = new Set; + const rankedDonors = []; + for (const placement of state.placements.values()) { + yield; + context.check(); + if (targetDates.includes(placement.date)) + continue; + rankedDonors.push({ + date: placement.date, + objectives: activeObjectiveVector(releasePlacement(state, placement.date), context) + }); + } + rankedDonors.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives) || compareDate(b.date, a.date)); + const fill = function* (index, current, additions = []) { + yield; + context.check(); + const key = JSON.stringify([ + index, + current.balances.map((balance) => [balance.id, balance.remaining]), + [...current.placements.values()].sort((a, b) => compareDate(a.date, b.date)).map((placement) => [ + placement.date, + targetDates.includes(placement.date) ? typedDates.has(placement.date) ? placement.type : null : placement.type, + targetDates.includes(placement.date) && !current.balances.find((balance) => balance.id === placement.balanceId).earned ? null : placement.balanceId, + [...placement.ruleIds].sort(), + placement.supporting, + additions.includes(placement.date) + ]) + ]); + if (failed.has(key)) + return false; + if (index === missing.length) { + const benefit = supportingCalendarBenefit(before, current, context); + for (const date2 of additions) { + const placement = { ...current.placements.get(date2) }; + current.placements.set(date2, placement); + const balance = current.balances.find((item) => item.id === placement.balanceId); + placement.supporting = benefit && !isAtRisk(balance, context); + } + const accepted = accept(current, additions.length > 0); + if (!accepted) + failed.add(key); + return accepted; + } + const date = missing[index]; + for (const balance of current.balances) { + if (balance.remaining <= 0 || date < balance.availableFrom || date > balance.expiryDate) + continue; + const assigned = assignDateToBalance(current, date, balance.id, context, new Set([ruleId])); + if (!assigned) + continue; + if (yield* fill(index + 1, assigned, [...additions, date])) + return true; + } + const donors = new Set; + for (const donor of rankedDonors) { + yield; + const source = current.placements.get(donor.date); + if (!source) + continue; + const balance = current.balances.find((item) => item.id === source.balanceId); + if (date < balance.availableFrom || date > balance.expiryDate || !mandatoryRelocationDate(date, balance, source.ruleIds, context)) + continue; + const without = releasePlacement(current, source.date); + const identity = JSON.stringify([ + source.balanceId, + [...source.ruleIds].sort(), + source.supporting, + activeObjectiveVector(without, context) + ]); + if (donors.has(identity)) + continue; + donors.add(identity); + const assigned = assignDateToBalance(without, date, source.balanceId, context, source.ruleIds); + if (!assigned) + continue; + assigned.placements.get(date).supporting = source.supporting; + if (yield* fill(index + 1, assigned, additions)) + return true; + } + failed.add(key); + return false; + }; + return yield* fill(0, state); +} +function* holidayWeekPlacementCandidates(state, ruleId, context, accept) { + for (const week of context.holidayWeeks) + yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept); +} +function* timeOffPeriodPlacementCandidates(state, ruleId, context, accept) { + const periods = periodData(state, context); + for (let index = 1;index < periods.length; index++) { + const dates = context.workingDays.filter((date) => date > periods[index - 1].end && date < periods[index].start); + if (dates.length > 1) + yield* calendarPlacementCandidates(state, dates, ruleId, context, accept); + } +} +function evaluateRuleCandidate(before, candidate, context, throughIndex, addition = false, baseline = activeObjectiveVector(before, context, throughIndex), incumbentVector = objectiveVector(before, context)) { + if (!candidate) + return; + const state = finalizeRuleCandidate(candidate, context, before); + if (!state) + return; + const active = []; + let comparison = 0; + let incumbentComparison = 0; + for (const objective of activeObjectives(state, context)) { + const index = active.length; + active.push(objective); + if (index <= throughIndex && comparison === 0) { + comparison = compareObjectiveValues(objective.direction, objective.value, baseline[index].value); + if (comparison > 0) + return; + } + if (incumbentComparison === 0) + incumbentComparison = compareObjectiveValues(objective.direction, objective.value, incumbentVector.objectives[index + 1].value); + if (incumbentComparison > 0 && (comparison < 0 || index >= throughIndex)) + return addition && comparison === 0 ? undefined : { state }; + } + const vector = objectiveVector(state, context, active); + if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0) + context.retain?.(state, undefined, vector); + if (addition && comparison === 0) + return; + return { state, vector }; +} +function* workSequencePlacementCandidates(state, rule, context, consider) { + const configuration = rule.configuration; + if (configuration.direction !== "minimize") + return; + const sequences = workSequenceDates(state, context).filter(({ dates, internal }) => dates.length > 0 && (!configuration.conditions.includes("internal") || internal)); + const bundles = []; + if (configuration.aggregation === "count") + bundles.push(...sequences.map(({ dates }) => dates.map((date) => [date]))); + else if (configuration.measure === "work-sequence-deficit") { + const short = sequences.filter(({ dates }) => dates.length < configuration.thresholdDays); + if (configuration.aggregation === "maximum") { + const shortest = Math.min(...short.map(({ dates }) => dates.length)); + bundles.push(short.filter(({ dates }) => dates.length === shortest).flatMap(({ dates }) => dates.map((date) => [date]))); + } else + bundles.push(...short.map(({ dates }) => dates.map((date) => [date]))); + } else if (configuration.measure === "work-sequence-length" && configuration.aggregation === "maximum") { + const maximum = Math.max(0, ...sequences.map(({ dates }) => dates.length)); + const tied = sequences.filter(({ dates }) => dates.length === maximum); + if (tied.length > 1) { + for (const offset of [0, 1]) { + const bundle = []; + for (const { dates } of tied) { + const middle = Math.floor((dates.length - 1 + offset) / 2); + const centered = dates.map((date, index) => ({ date, index })).sort((a, b) => Math.abs(a.index - middle) - Math.abs(b.index - middle) || compareDate(a.date, b.date)); + const eligible = [ + ...new Set(state.balances.filter((balance) => balance.remaining > 0).map((balance) => centered.find(({ date }) => balance.availableFrom <= date && date <= balance.expiryDate)?.date).filter((date) => date !== undefined)) + ]; + if (!eligible.length) + break; + bundle.push(eligible.sort((a, b) => centered.findIndex(({ date }) => date === a) - centered.findIndex(({ date }) => date === b))); + } + if (bundle.length === tied.length) + bundles.push(bundle); + } + } + } + const before = optimizationObservations(state, context); + const available = state.balances.reduce((total, balance) => total + balance.remaining, 0); + const typedDates = typedCalendarDates(context); + const failedAssignments = new Set; + function* assignBundle(candidate, slots, dates = []) { + yield; + context.check(); + const key = JSON.stringify([ + slots, + dates, + candidate.balances.map(({ id, remaining }) => [id, remaining]), + dates.map((date) => { + const placement = candidate.placements.get(date); + const balance = candidate.balances.find((item) => item.id === placement.balanceId); + return [ + typedDates.has(date) ? placement.type : undefined, + balance.earned ? balance.id : undefined + ]; + }) + ]); + if (failedAssignments.has(key)) + return false; + if (dates.length === slots.length) { + const supporting = supportingCalendarBenefit(before, candidate, context); + for (const date of dates) { + const placement = candidate.placements.get(date); + const balance = candidate.balances.find((item) => item.id === placement.balanceId); + placement.supporting = supporting && !isAtRisk(balance, context); + } + const accepted = consider(candidate); + if (!accepted) + failedAssignments.add(key); + return accepted; + } + for (const date of slots[dates.length]) { + for (const next of assignDateVariants(candidate, date, "any", rule.id, context)) + if (yield* assignBundle(next, slots, [...dates, date])) + return true; + } + failedAssignments.add(key); + return false; + } + const examined = new Set; + for (const slots of bundles) { + yield; + const key = JSON.stringify(slots); + if (slots.length <= 1 || slots.length > available || examined.has(key)) + continue; + examined.add(key); + yield* assignBundle(state, slots); + } +} +function* optimizeRulePlacements(state, context, throughIndex, allowAdd, rule) { + let result = state; + const addingBalanceQuantity = allowAdd && rule?.type === "optimization" && rule.configuration.elements === "leave-balances" && rule.configuration.measure === "unplaced-quantity" && rule.configuration.aggregation === "sum" && rule.configuration.direction === "minimize"; + const equivalentAdditionFunding = addingBalanceQuantity && throughIndex === 0 && !context.rules.some((item) => item.enabled && item.type === "if_then") && context.rules.every((item) => !item.enabled || item.type !== "optimization" || item.configuration.elements !== "leave-balances" || item.configuration.aggregation !== "maximum"); + const fundingWindows = context.rules.filter((item) => item.enabled && item.type === "time_window"); + const preferencePrefix = context.rules.filter((item) => item.enabled && item.force === "preference").slice(0, throughIndex + 1); + const prefixBounds = preferencePrefix.map((item, index) => { + if (index === 0) + return item.type === "optimization" && item.configuration.elements === "leave-balances" && item.configuration.measure === "unplaced-quantity" && item.configuration.aggregation === "sum" && item.configuration.direction === "minimize" ? 0 : undefined; + if (item.type === "time_window") + return item.placeDays; + if (item.type === "optimization" && item.configuration.elements === "public-holiday-weeks" && item.configuration.measure === "public-holiday-week-value" && item.configuration.direction === "maximize") + return evaluateOptimization(item.configuration, { + leaveBalances: [], + publicHolidayWeeks: context.holidayWeeks.map(({ value }) => ({ + value, + complete: true + })), + timeOffPeriods: [], + workSequences: [], + leavePlacements: [] + }); + return; + }); + const dateInvariantObjectives = context.rules.filter((item) => item.force === "preference").every((item) => item.type === "optimization" && (item.configuration.elements === "leave-balances" || item.configuration.elements === "leave-placements")); + while (true) { + yield; + context.check(); + const active = activeObjectiveVector(result, context); + const baseline = active.slice(0, throughIndex + 1); + const beforeVector = objectiveVector(result, context, active); + context.retain?.(result, undefined, beforeVector); + if (allowAdd && prefixBounds.length > 0 && prefixBounds.every((bound, index) => bound !== undefined && active[index].value === bound)) { + return result; + } + const genericStateKey = allowAdd && result.balances.every(({ remaining }) => remaining === 0) && !context.rules.some((item) => item.enabled && item.type === "if_then") ? preferenceStateKey(result) : undefined; + const reuseGeneric = genericStateKey !== undefined && context.completedGenericState === genericStateKey; + const compareEquivalentFunding = equivalentAdditionFunding && result.earnedActivations.length === 0 && Number.isSafeInteger(result.balances.reduce((sum, balance) => sum + balance.remaining, 0)); + let beforeCalendar; + let best = result; + let bestVector = beforeVector; + const consider = (candidate, addition = false) => { + const evaluated = evaluateRuleCandidate(result, candidate, context, throughIndex, addition, baseline, bestVector); + if (!evaluated) + return false; + if (!evaluated.vector) + return true; + const compared = compareObjectiveLists(evaluated.vector.objectives, bestVector.objectives); + if (compared >= 0) + return true; + best = evaluated.state; + bestVector = evaluated.vector; + return true; + }; + const dates = [...context.workingDays].sort((a, b) => Number(result.placements.has(a)) - Number(result.placements.has(b))); + const moveDates = result.placements.size <= 24 ? context.workingDays : context.workingDays.filter((date, index) => !isWorking(addDays(date, -1), context.holidays) || !isWorking(addDays(date, 1), context.holidays) || context.declaredDates.has(addDays(date, -1)) || context.declaredDates.has(addDays(date, 1)) || index % 7 === 0 || context.rules.some((item) => item.type === "time_window" && date >= item.startDate && date <= item.endDate)); + if (allowAdd && rule?.type === "optimization" && rule.configuration.elements === "work-sequences") + yield* workSequencePlacementCandidates(result, rule, context, (candidate) => consider(candidate, true)); + if (allowAdd && rule?.type === "optimization" && rule.configuration.elements === "public-holiday-weeks" && rule.configuration.conditions.includes("complete") && rule.configuration.direction === "maximize") + yield* holidayWeekPlacementCandidates(result, rule.id, context, consider); + if (allowAdd && rule?.type === "optimization" && rule.configuration.elements === "time-off-periods" && (rule.configuration.aggregation === "maximum" && rule.configuration.direction === "maximize" || rule.configuration.aggregation === "count" && rule.configuration.direction === "minimize")) + yield* timeOffPeriodPlacementCandidates(result, rule.id, context, consider); + const deferGeneric = allowAdd && best !== result && compareObjectiveLists(bestVector.objectives.slice(1, throughIndex + 2), baseline) < 0; + for (const date of reuseGeneric || deferGeneric ? [] : dates) { + yield; + context.check(); + if (context.declaredDates.has(date)) + continue; + const existing = result.placements.get(date); + if (!existing && allowAdd) { + if (rule?.type === "time_window" && (date < rule.startDate || date > rule.endDate)) + continue; + const evaluatedFunding = new Set; + for (const balance of result.balances) { + yield; + if (rule?.type === "time_window" && !selectorMatches(rule.placeType, balance.type)) + continue; + if (balance.remaining <= 0 || balance.availableFrom > date || balance.expiryDate < date) + continue; + const fundingKey = compareEquivalentFunding && isAtRisk(balance, context) ? `${balance.expiryDate}:${fundingWindows.map((window) => Number(date >= window.startDate && date <= window.endDate && selectorMatches(window.placeType, balance.type))).join("")}` : undefined; + if (fundingKey !== undefined && evaluatedFunding.has(fundingKey)) { + continue; + } + const assigned = assignDateToBalance(result, date, balance.id, context, rule ? new Set([rule.id]) : new Set); + if (assigned && rule?.type === "optimization" && !isAtRisk(balance, context)) { + beforeCalendar ??= optimizationObservations(result, context); + assigned.placements.get(date).supporting = supportingCalendarBenefit(beforeCalendar, assigned, context); + } + const evaluated = consider(assigned, true); + if (evaluated && fundingKey !== undefined) + evaluatedFunding.add(fundingKey); + } + } + if (!existing || addingBalanceQuantity && best !== result) + continue; + const without = releasePlacement(result, date); + consider(without); + for (const balance of without.balances) { + yield; + if (balance.id === existing.balanceId || balance.remaining <= 0 || balance.availableFrom > date || balance.expiryDate < date) + continue; + const reassigned = assignDateToBalance(without, date, balance.id, context, existing.ruleIds); + if (reassigned) + reassigned.placements.get(date).supporting = existing.supporting && !isAtRisk(balance, context); + consider(reassigned); + } + for (const destination of moveDates) { + yield; + context.check(); + if (destination === date || context.declaredDates.has(destination)) + continue; + const other = without.placements.get(destination); + if (other) { + if (other.balanceId === existing.balanceId) + continue; + const swapped = releasePlacement(without, destination); + const first = assignDateToBalance(swapped, destination, existing.balanceId, context, existing.ruleIds); + const second = first && assignDateToBalance(first, date, other.balanceId, context, other.ruleIds); + if (second) { + second.placements.get(destination).supporting = existing.supporting; + second.placements.get(date).supporting = other.supporting; + } + consider(second); + } else { + const moved = assignDateToBalance(without, destination, existing.balanceId, context, existing.ruleIds); + if (moved) + moved.placements.get(destination).supporting = existing.supporting; + if (moved && dateInvariantObjectives && compareObjectiveLists(tieBreakObjectives(moved), beforeVector.objectives.slice(-2)) >= 0) { + continue; + } + consider(moved); + } + } + } + if (allowAdd && (addingBalanceQuantity || rule?.type === "time_window" && Number(active[throughIndex].value) < rule.placeDays) && compareObjectiveLists(bestVector.objectives.slice(1, throughIndex + 2), baseline) === 0) { + for (const balance of result.balances) { + if (balance.remaining <= 0) + continue; + for (const date of result.placements.keys()) { + yield; + if (date < balance.availableFrom || date > balance.expiryDate) + continue; + const sourceRuleApplies = rule?.type !== "time_window" || date >= rule.startDate && date <= rule.endDate && selectorMatches(rule.placeType, balance.type); + yield* relocateForAddition(result, context, date, balance.id, rule && sourceRuleApplies ? new Set([rule.id]) : new Set, false, (candidate) => { + if (rule?.type === "optimization" && !isAtRisk(balance, context)) { + beforeCalendar ??= optimizationObservations(result, context); + candidate.placements.set(date, { + ...candidate.placements.get(date), + supporting: supportingCalendarBenefit(beforeCalendar, candidate, context) + }); + } + return consider(candidate, true); + }); + } + } + } + if (best === result) { + if (genericStateKey !== undefined) + context.completedGenericState = genericStateKey; + return result; + } + best = reconcileOptimizationProvenance(best, context, result, bestVector.objectives); + context.retain?.(best); + result = best; + } +} +function associateCalendarPreference(state, rule, context) { + const result = cloneState(state); + const dates = rule.type === "time_window" ? [...result.placements.values()].filter((placement) => placement.date >= rule.startDate && placement.date <= rule.endDate && selectorMatches(rule.placeType, placement.type)).sort((a, b) => Number(b.ruleIds.has(rule.id)) - Number(a.ruleIds.has(rule.id))).slice(0, Math.max(0, rule.placeDays - declaredWindowDays(rule, context).length)).map((placement) => placement.date) : occurrences(rule, context).flatMap((occurrence) => selectedOccurrencePattern(rule, occurrence, result, context) ?? []); + for (const date of dates) { + const placement = result.placements.get(date); + if (!placement) + continue; + placement.ruleIds.add(rule.id); + placement.supporting = false; + } + return result; +} +function* preferencePatternVariants(state, pattern, rule, context) { + let variants = [state]; + for (const date of pattern) { + yield; + const next = []; + for (const current of variants) { + yield; + const existing = current.placements.get(date); + if (!existing || selectorMatches(rule.placeType, existing.type)) { + if (declaredPatternDay(date, rule.placeType, context)) + next.push(current); + else + next.push(...assignDateVariants(current, date, rule.placeType, rule.id, context)); + continue; + } + const without = releasePlacement(current, date); + for (const balance of without.balances) { + yield; + if (!selectorMatches(rule.placeType, balance.type)) + continue; + const reassigned = assignDateToBalance(without, date, balance.id, context, new Set([rule.id])); + if (reassigned) + next.push(reassigned); + } + for (const other of without.placements.values()) { + yield; + if (pattern.includes(other.date) || !selectorMatches(rule.placeType, other.type)) + continue; + const released = releasePlacement(without, other.date); + const first = assignDateToBalance(released, date, other.balanceId, context, new Set([...other.ruleIds, rule.id])); + const swapped = first && assignDateToBalance(first, other.date, existing.balanceId, context, existing.ruleIds); + if (!swapped) + continue; + swapped.placements.get(date).supporting = other.supporting; + swapped.placements.get(other.date).supporting = existing.supporting; + next.push(swapped); + } + } + variants = next; + if (!variants.length) + break; + } + return variants; +} +function* preferenceIfThen(state, rule, context, preferenceIndex) { + const matchingOccurrences = occurrences(rule, context); + let bestState = state; + let bestCount = 0; + const baseline = activeObjectiveVector(state, context, preferenceIndex); + const search = function* (index, current, count) { + yield; + context.check(); + const maximumCount = count + matchingOccurrences.length - index; + if (maximumCount < bestCount && baseline.slice(0, preferenceIndex).every((objective) => { + if (objective.direction === "minimize") + return objective.value === 0; + const earlier = context.rules.find((item) => `preference:${item.id}` === objective.id); + return earlier && objective.value === (earlier.type === "time_window" ? earlier.placeDays : occurrences(earlier, context).length); + })) { + return; + } + if (index >= matchingOccurrences.length) { + const finalized = finalizeRuleCandidate(current, context, state); + if (!finalized) { + return; + } + if (unsatisfiedObligationCount(finalized, context) || compareObjectiveLists(activeObjectiveVector(finalized, context, preferenceIndex), baseline) > 0) + return; + context.retain?.(finalized); + const better = compareObligationPreviews({ state: finalized }, { state: bestState }, context) < 0; + if (better) { + bestCount = count; + bestState = finalized; + } + return; + } + const occurrence = matchingOccurrences[index]; + const patterns = [...patternsFor(rule, occurrence, context)].sort((a, b) => patternConflictScore(rule.placeType, a, context) - patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b)); + for (const pattern of patterns) { + yield; + const variants = yield* preferencePatternVariants(current, pattern, rule, context); + for (const variant of variants) { + yield; + const finalized = finalizeEarnedState(addEarnedBalance(variant, rule, occurrence, pattern, context), context); + if (!finalized) + continue; + yield* search(index + 1, finalized, count + 1); + } + } + yield* search(index + 1, current, count); + }; + yield* search(0, state, 0); + context.retain?.(bestState); + return { state: bestState, count: bestCount }; +} +function preferenceDateConflictScore(selector, date, context) { + const protectedTypes = context.protectedTypesByDate.get(date); + const demand = context.preferenceDemandByDate.get(date) ?? 0; + const anyDemand = context.anyPreferenceDemandByDate.get(date) ?? 0; + if (!protectedTypes?.size && !demand) + return 0; + if (selector === "any") + return -(demand * 2 + (protectedTypes?.size ?? 0)); + if (protectedTypes?.has(selector)) + return -(demand * 2 + 1); + if (protectedTypes?.size) + return anyDemand ? -anyDemand : protectedTypes.size; + return demand ? -demand : 0; +} +function patternConflictScore(selector, pattern, context) { + return pattern.reduce((score, date) => score + preferenceDateConflictScore(selector, date, context), 0); +} +function preferenceReason(rule, count, context, state) { + const requested = rule.type === "if_then" ? occurrences(rule, context).length : rule.placeDays; + if (count >= requested) + return; + const candidateDates = rule.type === "time_window" ? context.workingDays.filter((date) => !context.declaredDates.has(date) && compareDate(date, rule.startDate) >= 0 && compareDate(date, rule.endDate) <= 0) : occurrences(rule, context).flatMap((occurrence) => patternsFor(rule, occurrence, context).flat()); + if (!candidateDates.length) + return reason("horizon", `Preference "${rule.name}" has no placeable date in the horizon.`, [rule.id]); + const matchingBalances = state.balances.filter((balance) => selectorMatches(rule.placeType, balance.type)); + const availableBalances = matchingBalances.filter((balance) => candidateDates.some((date) => compareDate(balance.availableFrom, date) <= 0 && compareDate(date, balance.expiryDate) <= 0)); + const currentRuleIndex = context.rules.findIndex((item) => item.id === rule.id); + const consumedByHigherPriority = matchingBalances.some((balance) => balance.remaining === 0 && [...state.placements.values()].some((placement) => placement.balanceId === balance.id && [...placement.ruleIds].some((id) => { + const index = context.rules.findIndex((item) => item.id === id); + return index >= 0 && (context.rules[index].force === "obligation" || index < currentRuleIndex); + }))); + const freeEligibleDate = candidateDates.some((date) => !state.placements.has(date) && state.balances.some((balance) => balance.remaining > 0 && selectorMatches(rule.placeType, balance.type) && compareDate(balance.availableFrom, date) <= 0 && compareDate(date, balance.expiryDate) <= 0)); + const compatibleExisting = candidateDates.some((date) => { + const placement = state.placements.get(date); + return placement && selectorMatches(rule.placeType, placement.type); + }); + const blockedByHigherPriority = candidateDates.some((date) => { + const placement = state.placements.get(date); + return placement && !selectorMatches(rule.placeType, placement.type) && [...placement.ruleIds].some((id) => { + const index = context.rules.findIndex((item) => item.id === id); + return index >= 0 && (context.rules[index].force === "obligation" || index < currentRuleIndex); + }); + }); + let code; + if (blockedByHigherPriority && !freeEligibleDate && !compatibleExisting) + code = "higher-priority"; + else if (!matchingBalances.length && !compatibleExisting) + code = "type"; + else if (!availableBalances.length) + code = "availability"; + else if (consumedByHigherPriority && !freeEligibleDate && !compatibleExisting) + code = "higher-priority"; + else + code = "capacity"; + return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]); +} +function* applyPreferenceRules(state, preferenceRules, context) { + let result = state; + const preferences = []; + for (let preferenceIndex = 0;preferenceIndex < preferenceRules.length; preferenceIndex++) { + yield; + result = yield* applyPreferenceStep(result, preferenceRules, preferenceIndex, preferences, context); + } + return { state: result, preferences }; +} +function* applyPreferenceStep(state, preferenceRules, preferenceIndex, preferences, context) { + let result = state; + const apply = function* (index) { + const rule = preferenceRules[index]; + context.protectedTypesByDate = futurePreferenceProtection(preferenceRules, index + 1, context); + if (context.completedPreferenceStates.get(index) === preferenceStateKey(result)) { + return; + } + if (rule.type === "optimization") + result = yield* optimizeRulePlacements(result, context, index, true, rule); + else if (rule.type === "time_window") + result = associateCalendarPreference(yield* optimizeRulePlacements(result, context, index, true, rule), rule, context); + else + result = associateCalendarPreference((yield* preferenceIfThen(result, rule, context, index)).state, rule, context); + context.completedPreferenceStates.set(index, preferenceStateKey(result)); + }; + yield* apply(preferenceIndex); + let previous; + do { + previous = activeObjectiveVector(result, context); + const entitlements = result.balances.map(({ id, quantity, availableFrom, expiryDate }) => `${id}:${quantity}:${availableFrom}:${expiryDate}`).join("|"); + for (let index = 0;index < preferenceIndex; index++) { + yield; + yield* apply(index); + } + if (compareObjectiveLists(activeObjectiveVector(result, context), previous) < 0 || result.balances.map(({ id, quantity, availableFrom, expiryDate }) => `${id}:${quantity}:${availableFrom}:${expiryDate}`).join("|") !== entitlements) + yield* apply(preferenceIndex); + } while (compareObjectiveLists(activeObjectiveVector(result, context), previous) < 0); + preferences.splice(0, preferences.length, ...preferenceResultsForState(result, context).map((preference) => ({ + ...preference, + reason: preferenceReason(context.rules.find((rule) => rule.type !== "optimization" && rule.id === preference.ruleId), preference.satisfied, context, result) + }))); + return result; +} +function preferenceStateKey(state) { + return JSON.stringify([ + state.balances, + [...state.placements].map(([date, placement]) => [ + date, + { ...placement, ruleIds: [...placement.ruleIds] } + ]), + state.earnedActivations + ]); +} +function placementTieKey(state) { + return [...state.placements.keys()].sort().join("|"); +} +function expiryTieKey(state) { + return [...state.placements.values()].map((placement) => state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? "").sort(compareDate).join("|"); +} +function activeObjectiveVector(state, context, throughIndex = Number.POSITIVE_INFINITY) { + return [...activeObjectives(state, context, throughIndex)]; +} +function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINITY) { + let preferences; + let balances; + let calendar; + let index = 0; + for (const rule of context.rules) { + if (!rule.enabled || rule.force !== "preference") + continue; + if (index++ > throughIndex) + return; + if (rule.type === "optimization") { + const observations = rule.configuration.elements === "leave-balances" ? balances ??= { + leaveBalances: optimizationBalanceObservations(state, context), + publicHolidayWeeks: [], + timeOffPeriods: [], + workSequences: [], + leavePlacements: [] + } : calendar ??= optimizationObservations(state, context); + yield { + id: `optimization:${rule.id}`, + direction: rule.configuration.direction, + value: evaluateStateOptimization(rule.configuration, state, context, observations), + label: rule.name + }; + } else { + preferences ??= preferenceResultsForState(state, context); + yield { + id: `preference:${rule.id}`, + direction: "maximize", + value: preferences.find((preference) => preference.ruleId === rule.id)?.satisfied ?? 0, + label: rule.name + }; + } + } +} +function tieBreakObjectives(state) { + return [ + { + id: "balance-expiry", + direction: "minimize", + value: expiryTieKey(state), + label: "Balance expiry tie-break" + }, + { + id: "placement-date", + direction: "minimize", + value: placementTieKey(state), + label: "Placement date tie-break" + } + ]; +} +function obligationPreviewObjectives(preview, context) { + return [...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state)]; +} +function compareObligationPreviews(a, b, context) { + return compareObjectiveLists(obligationPreviewObjectives(a, context), obligationPreviewObjectives(b, context)); +} +function isAtRisk(balance, context) { + return compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0; +} +function isNaturalNonWorkingDate(date, context) { + return context.calendarIndex.has(date) ? !context.workingCalendar.has(date) : !isWorkingPlanningDate(date); +} +function calendarDateAt(index, context) { + return context.calendarDays[index] ?? addDays(context.calendarDays[0], index); +} +function periodBounds(state, context) { + const dates = [...new Set([...state.placements.keys(), ...context.declaredDates])].filter((date) => date >= context.horizon.start && date < context.horizon.end).sort(compareDate); + if (!dates.length) + return []; + const groups = []; + let group = [dates[0]]; + for (let i = 1;i < dates.length; i++) { + context.check(); + const previousIndex = context.calendarIndex.get(dates[i - 1]); + const currentIndex = context.calendarIndex.get(dates[i]); + const workingInGap = context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1]; + if (workingInGap === 0) + group.push(dates[i]); + else { + groups.push(group); + group = [dates[i]]; + } + } + groups.push(group); + return groups.map((anchors) => { + context.check(); + let startIndex = context.calendarIndex.get(anchors[0]); + let endIndex = context.calendarIndex.get(anchors[anchors.length - 1]); + while (context.declaredDates.has(calendarDateAt(startIndex - 1, context)) || isNaturalNonWorkingDate(calendarDateAt(startIndex - 1, context), context)) { + context.check(); + startIndex--; + } + while (context.declaredDates.has(calendarDateAt(endIndex + 1, context)) || isNaturalNonWorkingDate(calendarDateAt(endIndex + 1, context), context)) { + context.check(); + endIndex++; + } + return { anchors, startIndex, endIndex }; + }); +} +function calendarPeriod(startIndex, endIndex, context) { + let periods = calendarPeriodCache.get(context); + if (!periods) { + periods = new Map; + calendarPeriodCache.set(context, periods); + } + const key = `${startIndex}:${endIndex}`; + let period = periods.get(key); + if (period) + periods.delete(key); + else { + const start = calendarDateAt(startIndex, context); + const end = calendarDateAt(endIndex, context); + const dates = startIndex >= 0 && endIndex < context.calendarDays.length ? context.calendarDays.slice(startIndex, endIndex + 1) : dateRange(start, addDays(end, 1)); + period = Object.freeze({ + start, + end, + duration: dates.length, + nonWorkingDates: Object.freeze(dates.filter((date) => isNaturalNonWorkingDate(date, context))), + declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))) + }); + } + periods.set(key, period); + if (periods.size > 4096) + periods.delete(periods.keys().next().value); + return period; +} +function periodData(state, context) { + return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => { + const period = calendarPeriod(startIndex, endIndex, context); + return { + start: period.start, + end: period.end, + duration: period.duration, + placementDates: anchors.filter((date) => state.placements.has(date)), + ...period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}, + nonWorkingDates: [...period.nonWorkingDates], + nonWorkingDayGain: period.nonWorkingDates.length + }; + }); +} +function optimizationBalanceObservations(state, context) { + return state.balances.map((balance) => ({ + unplacedQuantity: balance.remaining, + atRisk: isAtRisk(balance, context) + })); +} +function optimizationObservations(state, context, periods) { + const key = placementTieKey(state); + let byDates = calendarObservationCache.get(context); + if (!byDates) { + byDates = new Map; + calendarObservationCache.set(context, byDates); + } + const calendar = byDates.get(key) ?? {}; + if (byDates.has(key)) + byDates.delete(key); + else if (byDates.size >= 4096) + byDates.delete(byDates.keys().next().value); + byDates.set(key, calendar); + let bounds; + return { + leaveBalances: optimizationBalanceObservations(state, context), + get publicHolidayWeeks() { + return calendar.publicHolidayWeeks ??= Object.freeze(context.holidayWeeks.map((week) => Object.freeze({ + value: week.value, + complete: week.dates.every((date) => state.placements.has(date) || context.declaredDates.has(date)) + }))); + }, + get timeOffPeriods() { + return calendar.timeOffPeriods ??= Object.freeze(periods ? periods.map((period) => Object.freeze({ + nonWorkingDates: Object.freeze([...period.nonWorkingDates]) + })) : (bounds ??= periodBounds(state, context)).map(({ startIndex, endIndex }) => Object.freeze({ + nonWorkingDates: calendarPeriod(startIndex, endIndex, context).nonWorkingDates + }))); + }, + get workSequences() { + return calendar.workSequences ??= Object.freeze(workSequenceLengths(bounds ??= periodBounds(state, context), context).map((sequence) => Object.freeze(sequence))); + }, + leavePlacements: [...state.placements.values()] + }; +} +function workSequenceLengths(bounds, context) { + if (!context.workingDays.length) + return []; + let nextIndex = context.calendarIndex.get(context.horizon.start); + const sequences = bounds.map(({ anchors }, index) => { + const previousIndex = nextIndex; + nextIndex = context.calendarIndex.get(anchors[anchors.length - 1]) + 1; + return { + length: context.workingPrefix[context.calendarIndex.get(anchors[0])] - context.workingPrefix[previousIndex], + internal: index > 0 + }; + }); + sequences.push({ + length: context.workingPrefix[context.calendarIndex.get(context.horizon.end)] - context.workingPrefix[nextIndex], + internal: false + }); + return sequences; +} +function workSequenceDates(state, context) { + const sequences = []; + let current = []; + let inTimeOffPeriod = false; + for (const date of context.workingDays) { + context.check(); + if (state.placements.has(date) || context.declaredDates.has(date)) { + if (!inTimeOffPeriod) + sequences.push(current); + current = []; + inTimeOffPeriod = true; + } else { + current.push(date); + inTimeOffPeriod = false; + } + } + if (context.workingDays.length) + sequences.push(current); + return sequences.map((dates, index) => ({ + dates, + internal: index > 0 && index < sequences.length - 1 + })); +} +function evaluateStateOptimization(configuration, state, context, observations = optimizationObservations(state, context)) { + return evaluateOptimization(configuration, observations); +} +function qualityMetrics(state, context, preferences) { + const periods = periodData(state, context); + const observations = optimizationObservations(state, context, periods); + const nonEmptySequences = observations.workSequences.filter((sequence) => sequence.length > 0); + return { + unplacedAtRisk: evaluateStateOptimization(OPTIMIZATION_PRESETS.unplacedAtRisk, state, context, observations), + holidayWeekValue: evaluateStateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, state, context, observations), + nonWorkingDayGain: evaluateStateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, state, context, observations), + longestWorkSequence: evaluateStateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, state, context, observations), + averageWorkSequence: nonEmptySequences.length ? nonEmptySequences.reduce((sum, sequence) => sum + sequence.length, 0) / nonEmptySequences.length : null, + averageTimeOffPeriod: periods.length ? periods.reduce((sum, period) => sum + period.duration, 0) / periods.length : null, + longestTimeOffPeriod: periods.length ? Math.max(...periods.map((period) => period.duration)) : null, + shortSequenceDeficit: evaluateStateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, state, context, observations), + supportingPlacements: evaluateStateOptimization(OPTIMIZATION_PRESETS.supportingPlacements, state, context, observations), + preferenceSatisfaction: preferences.map((preference) => preference.satisfied) + }; +} +function nonPlacementReason(balance, state, context, completed) { + if (balance.expiryDate < context.horizon.start) + return reason("unavoidable-expiry", "This balance expired before the planning horizon."); + if (balance.availableFrom >= context.horizon.end) + return reason("available-after-horizon", "This balance becomes available after the last planning date."); + const dates = context.workingDays.filter((date) => date >= balance.availableFrom && date <= balance.expiryDate); + if (!dates.length) + return reason("no-working-date", "There is no working date within both the horizon and this balance’s validity period."); + if (dates.every((date) => context.declaredDates.has(date) || state.placements.has(date))) + return reason("all-dates-occupied", "Every working date within this balance’s validity period is already occupied in the displayed plan."); + if (!completed) + return reason("calculation-incomplete", "The calculation stopped before completing its planning priorities."); + if (balance.expiryDate > context.horizon.end) + return reason("preserved", "This balance expires after the horizon; the planner retained no additional placement under its planning priorities."); + return reason("active-priorities", "The calculation retained no additional placement under the active planning rules."); +} +function remainingBalances(state, context, completed) { + return state.balances.filter((balance) => balance.remaining > 0).map((balance) => { + const classification = compareDate(balance.expiryDate, context.horizon.start) < 0 ? "unavoidable-expiry" : compareDate(balance.expiryDate, context.horizon.end) <= 0 ? "at-risk" : "deferred"; + return { + id: balance.id, + type: balance.type, + quantity: balance.remaining, + availableFrom: balance.availableFrom, + expiryDate: balance.expiryDate, + classification, + reason: nonPlacementReason(balance, state, context, completed), + earned: balance.earned, + sourceRuleId: balance.sourceRuleId + }; + }); +} +function unsatisfiedObligationCount(state, context) { + const placementCounts = new Map; + for (const placement of state.placements.values()) + for (const ruleId of placement.ruleIds) + placementCounts.set(ruleId, (placementCounts.get(ruleId) ?? 0) + 1); + let unsatisfied = 0; + for (const requirement of buildRequirements(context.rules, context, "obligation")) { + if (!requirement.patterns) { + if ((placementCounts.get(requirement.rule.id) ?? 0) !== requirement.requiredPlacements) + unsatisfied++; + continue; + } + let complete = false; + for (const pattern of requirement.patterns) { + if (pattern.every((date) => patternDateSatisfied(date, requirement.rule, state, context))) { + complete = true; + break; + } + } + if (!complete) + unsatisfied++; + } + return unsatisfied; +} +function objectiveVector(state, context, active = activeObjectiveVector(state, context)) { + return { + objectives: [ + { + id: "obligation-validity", + direction: "minimize", + value: unsatisfiedObligationCount(state, context), + label: "Unsatisfied obligations" + }, + ...active, + ...tieBreakObjectives(state) + ] + }; +} +function displayLabel(value, fallback) { + return typeof value === "string" && value.trim() ? value : fallback; +} +function timeWindowContributions(state, context) { + if (!context.declaredDays.length) + return []; + const labels = recordValue(context.request.labels); + const identities = recordValue(context.request.identities); + return context.rules.filter((rule) => rule.enabled && rule.type === "time_window").flatMap((rule) => { + const declaredDays = declaredWindowDays(rule, context); + return declaredDays.length ? [ + { + ruleId: rule.id, + label: displayLabel(labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name, rule.id), + requested: rule.placeDays, + declaredDays, + placementDates: [...state.placements.values()].filter((placement) => placement.ruleIds.has(rule.id)).map(({ date }) => date).sort(compareDate) + } + ] : []; + }); +} +function ifThenContributions(state, context) { + const labels = recordValue(context.request.labels); + const identities = recordValue(context.request.identities); + return context.rules.filter((rule) => rule.enabled && rule.type === "if_then").flatMap((rule) => occurrences(rule, context).flatMap((occurrence) => { + const id = `earned:${rule.id}:${occurrence}`; + const activation = state.earnedActivations.find((item) => item.id === id); + const pattern = selectedOccurrencePattern(rule, occurrence, state, context); + if (!pattern) + return []; + const declaredDays = pattern.flatMap((date) => { + const day = declaredPatternDay(date, rule.placeType, context); + return day ? [day] : []; + }); + if (!declaredDays.length && !activation) + return []; + const bounds = activation ? earnedPeriodBounds(state, activation, context) : undefined; + return [ + { + ruleId: rule.id, + label: displayLabel(labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name, rule.id), + occurrence, + requested: rule.placeDays, + declaredDays, + placementDates: pattern.filter((date) => !context.declaredDates.has(date)), + ...activation && bounds ? { + earnedEntitlement: { + id, + type: rule.rewardType, + quantity: rule.rewardDays, + ...bounds, + added: bounds.availableFrom > context.request.calculationDate + } + } : {} + } + ]; + })); +} +function planExplanation(state, context, placements, periods, preferences, remaining, metrics) { + const source = context.request; + const rawBalances = Array.isArray(source.balances) ? source.balances : []; + const rawRules = Array.isArray(source.rules) ? source.rules : []; + const labels = recordValue(source.labels); + const identities = recordValue(source.identities); + const vector = objectiveVector(state, context); + const windowContributions = timeWindowContributions(state, context); + const patternContributions = ifThenContributions(state, context); + return { + kind: "PlanExplanation", + calculationDate: context.request.calculationDate, + ...context.declaredDays.length ? { declaredDays: context.declaredDays } : {}, + timeOffPeriods: periods, + ...windowContributions.length ? { timeWindowContributions: windowContributions } : {}, + ...patternContributions.length ? { ifThenContributions: patternContributions } : {}, + placements: placements.map((placement) => { + const balance = rawBalances.map(recordValue).find((candidate) => candidate?.id === placement.balanceId); + const normalizedBalance = state.balances.find((candidate) => candidate.id === placement.balanceId); + const period = periods.find((candidate) => candidate.placementDates.includes(placement.date)); + const sourceRules = placement.ruleIds.map((id) => { + const rule = context.rules.find((candidate) => candidate.id === id); + const rawRule = rawRules.map(recordValue).find((candidate) => candidate?.id === id); + const label = displayLabel(labels?.[id] ?? identities?.[id] ?? rawRule?.name ?? rule?.name, id); + return { id, label }; + }); + const atRisk = normalizedBalance ? compareDate(normalizedBalance.expiryDate, context.horizon.start) >= 0 && compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0 : false; + const withoutPlacement = cloneState(state); + withoutPlacement.placements.delete(placement.date); + const released = withoutPlacement.balances.find((candidate) => candidate.id === placement.balanceId); + if (released) + released.remaining++; + const comparison = objectiveComparison(objectiveVector(withoutPlacement, context), vector); + const contributions = [ + { + kind: "entitlement", + code: "balance", + label: displayLabel(labels?.[placement.balanceId] ?? identities?.[placement.balanceId] ?? balance?.label ?? balance?.name ?? balance?.id, placement.balanceId) + }, + ...sourceRules.map((rule) => ({ + kind: "rule", + code: "rule", + label: rule.label, + ruleId: rule.id + })) + ]; + if (atRisk) + contributions.push({ + kind: "at-risk", + code: "at-risk", + label: "Protects leave at risk of expiry." + }); + if (placement.supporting) + contributions.push({ + kind: "supporting", + code: "supporting", + label: "Supports the surrounding time-off plan." + }); + if (period) + contributions.push({ + kind: "time-off-period", + code: "time-off-period", + label: `Contributes to the time-off period ${period.start}–${period.end}.`, + dates: period.nonWorkingDates + }); + for (const delta of comparison.delta) { + if (typeof delta.value !== "number" || delta.value === 0) + continue; + const objective = vector.objectives.find((candidate) => candidate.id === delta.id); + contributions.push({ + kind: "metric", + code: objective.id, + label: objective.label ?? objective.id, + value: delta.value + }); + } + const balanceSnapshot = balance ? { + id: typeof balance.id === "string" ? balance.id : placement.balanceId, + type: typeof balance.type === "string" ? balance.type : placement.type, + quantity: typeof balance.quantity === "number" ? balance.quantity : 0, + name: typeof balance.name === "string" ? balance.name : undefined, + label: typeof balance.label === "string" ? balance.label : undefined, + availableFrom: typeof balance.availableFrom === "string" || balance.availableFrom instanceof Date ? balance.availableFrom : normalizedBalance?.availableFrom, + expiryDate: typeof balance.expiryDate === "string" || balance.expiryDate instanceof Date ? balance.expiryDate : normalizedBalance?.expiryDate ?? "" + } : normalizedBalance ? { + id: normalizedBalance.id, + type: normalizedBalance.type, + quantity: normalizedBalance.quantity, + name: normalizedBalance.sourceRuleId ? `Earned from ${displayLabel(labels?.[normalizedBalance.sourceRuleId], normalizedBalance.sourceRuleId)}` : undefined, + availableFrom: normalizedBalance.availableFrom, + expiryDate: normalizedBalance.expiryDate + } : undefined; + return { + placement: { ...placement, ruleIds: [...placement.ruleIds] }, + balance: balanceSnapshot, + ruleIds: [...placement.ruleIds], + supporting: placement.supporting, + timeOffPeriod: period, + remainingBalance: remaining.find((candidate) => candidate.id === placement.balanceId), + remainingQuantity: normalizedBalance?.remaining ?? 0, + contributions, + finalMetrics: metrics, + objectiveVector: vector, + comparison, + firstDecisiveObjective: comparison.firstDecisiveObjective + }; + }) + }; +} +function makePlan(state, context, preferences, stages) { + const periods = periodData(state, context); + const metrics = qualityMetrics(state, context, preferences); + const placements = [...state.placements.values()].sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)).map((placement) => ({ + date: placement.date, + balanceId: placement.balanceId, + type: placement.type, + ruleIds: [...placement.ruleIds].sort(), + supporting: placement.supporting + })); + const remaining = remainingBalances(state, context, stages.includes("dates")); + const windowContributions = timeWindowContributions(state, context); + const patternContributions = ifThenContributions(state, context); + const plan = { + kind: "Plan", + calculationDate: context.request.calculationDate, + ...context.declaredDays.length ? { declaredDays: context.declaredDays } : {}, + ...windowContributions.length ? { timeWindowContributions: windowContributions } : {}, + ...patternContributions.length ? { ifThenContributions: patternContributions } : {}, + horizon: context.horizon, + placements, + timeOffPeriods: periods, + preferences, + remainingBalances: remaining, + metrics, + completedStages: [...stages], + lastCompletedStage: stages[stages.length - 1] ?? "validation" + }; + context.retain?.(state); + return plan; +} +function markProgress(context, stage) { + if (typeof context.request.progress === "function") + observeCallback(context, () => context.request.progress(stage)); +} +function ruleStage(rule) { + return `${rule.type === "optimization" ? "optimization" : "preference"}:${rule.id}`; +} +function planningStages(context) { + return [ + "validation", + "obligations", + ...context.rules.filter((rule) => rule.enabled && rule.force === "preference").map(ruleStage), + "expiry", + "dates" + ]; +} +function conflictExplanation(outcome, context) { + const labels = recordValue(context.request.labels); + return { + kind: "ConflictExplanation", + conflictingRules: outcome.conflictingRuleIds.map((id) => ({ + id, + label: displayLabel(labels?.[id] ?? context.rules.find((rule) => rule.id === id)?.name, id) + })), + bottleneck: outcome.reasons[0] + }; +} +function obligationConflict(requirements, context) { + const ruleIds = [...new Set(requirements.map((requirement) => requirement.rule.id))]; + const generatedTypes = new Set(context.rules.filter((rule) => rule.enabled && rule.type === "if_then" && (rule.rewardDays ?? 0) > 0 && !!rule.rewardType).map((rule) => rule.rewardType)); + let generatedCapacityBottleneck = false; + for (const requirement of requirements) { + if (requirement.rule.placeType === "any") + continue; + const required = requirement.requiredPlacements; + const capacity = context.initialBalances.filter((balance) => balance.type === requirement.rule.placeType).reduce((sum, balance) => sum + balance.quantity, 0); + if (capacity < required) { + if (!generatedTypes.has(requirement.rule.placeType)) + return { + kind: "PlanningConflict", + conflictingRuleIds: ruleIds, + reasons: [ + reason("typed-capacity", `The obligations require more ${requirement.rule.placeType.toUpperCase()} leave than is available.`, ruleIds) + ] + }; + generatedCapacityBottleneck = true; + } + } + if (requirements.some((requirement) => requirement.patterns !== undefined && requirement.patterns.length === 0 || requirement.candidates !== undefined && requirement.requiredPlacements > 0 && requirement.candidates.length === 0)) + return { + kind: "PlanningConflict", + conflictingRuleIds: ruleIds, + reasons: [ + reason("horizon-bottleneck", "The planning horizon contains no eligible dates for at least one obligation.", ruleIds) + ] + }; + if (generatedCapacityBottleneck) + return { + kind: "PlanningConflict", + conflictingRuleIds: ruleIds, + reasons: [ + reason("earned-entitlement-bottleneck", "An earned leave entitlement is not available early enough to satisfy every obligation.", ruleIds) + ] + }; + return { + kind: "PlanningConflict", + conflictingRuleIds: ruleIds, + reasons: [ + reason("joint-date-conflict", "The obligation date patterns cannot be satisfied together.", ruleIds) + ] + }; +} +function calculatePlanningInternal(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback) { + const steps = calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback); + let next = steps.next(); + while (!next.done) + next = steps.next(); + return next.value; +} +async function calculatePlanningAsyncInternal(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, isCancelled = () => false) { + const steps = calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, true); + let next = steps.next(); + let pauseAt = now() + 8; + while (!next.done) { + if (next.value || now() >= pauseAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + pauseAt = now() + 8; + } + next = isCancelled() ? steps.throw(new UserCancelled) : steps.next(); + } + return next.value; +} +function* prepareCalendar(horizon, progress) { + const start = addDays(horizon.start, -400); + const end = addDays(horizon.end, 400); + const holidays = new Set; + for (let year = Number(start.slice(0, 4));year <= Number(end.slice(0, 4)); year++) { + yield true; + for (const holiday of frenchPublicHolidaysForYear(year)) + holidays.add(holiday); + progress(); + } + const workingDays = []; + const calendarDays = []; + const calendarIndex = new Map; + const workingCalendar = new Set; + const workingPrefix = [0]; + for (let date = start;compareDate(date, end) < 0; date = addDays(date, 1)) { + yield; + calendarIndex.set(date, calendarDays.length); + calendarDays.push(date); + const working = isWorking(date, holidays); + if (working) { + workingCalendar.add(date); + if (date >= horizon.start && date < horizon.end) + workingDays.push(date); + } + workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working)); + } + return { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix }; +} +function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, retainIncumbents = false) { + const source = runtimeRequest(request); + const normalized = normalizeRequest(source); + if (normalized.reasons.length) { + const outcome = { ...invalidInputFor(source), calculationDate: source.calculationDate }; + outcomeCallback?.(outcome); + artifactsCallback?.({}); + return outcome; + } + const horizon = normalized.horizon; + let calendar; + try { + if (typeof source.progress === "function") + source.progress("preparation"); + yield true; + calendar = yield* prepareCalendar(horizon, () => { + if (typeof source.progress === "function") + source.progress("preparation"); + }); + } catch (error) { + if (!(error instanceof UserCancelled)) + throw error; + const outcome = { + kind: "PlanningIncomplete", + calculationDate: source.calculationDate, + completedStages: ["validation"], + lastCompletedStage: "validation", + reasons: [reason("user-cancelled", "The calculation was cancelled by the user.")] + }; + const explanation = { + kind: "IncompleteExplanation", + calculationDate: source.calculationDate, + reason: outcome.reasons[0], + completedStages: outcome.completedStages, + uncompletedStages: ["preparation", "obligations"] + }; + outcomeCallback?.(outcome); + explanationCallback?.(explanation, undefined, undefined); + artifactsCallback?.({ + explanation + }); + return outcome; + } + const { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix } = calendar; + const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + const deadline = typeof source.deadlineMs === "number" ? source.deadlineMs : Number.POSITIVE_INFINITY; + const context = { + horizon, + holidays, + holidayWeeks: [], + calendarDays, + calendarIndex, + workingCalendar, + workingPrefix, + workingDays, + declaredDays: normalized.declaredDays, + declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)), + initialBalances: normalized.balances, + rules: normalized.rules, + deadline, + started, + request: source, + nodes: 0, + check: () => { + return; + }, + reservedTypes: new Set, + sharedTypesByDate: new Map, + protectedTypesByDate: new Map, + preferenceDemandByDate: new Map, + anyPreferenceDemandByDate: new Map, + observerTime: 0, + completedPreferenceStates: new Map + }; + context.holidayWeeks = holidayWeekCandidates(context); + context.check = () => { + context.nodes++; + if (context.deadline <= 1 || context.nodes % 16 === 0) { + if (now() - context.started - context.observerTime >= context.deadline) + throw new DeadlineReached; + } + }; + const initialState = { + balances: normalized.balances.map((balance) => ({ + ...balance, + remaining: balance.quantity + })), + placements: new Map, + earnedActivations: [] + }; + const stages = ["validation"]; + let state = initialState; + const preferences = []; + let checkpoint; + let checkpointState; + let retained; + if (retainIncumbents) + context.retain = (candidate, finalizedMetrics, candidateVector) => { + const check = context.check; + context.check = () => { + return; + }; + try { + const finalized = finalizedMetrics || candidateVector ? candidate : finalizeEarnedState(candidate, context); + if (!finalized) + return; + if (unsatisfiedObligationCount(finalized, context)) + return; + if (retained && preferenceStateKey(retained.state) === preferenceStateKey(finalized)) + return; + const vector = candidateVector ?? objectiveVector(finalized, context); + const compared = retained ? compareObjectiveLists(vector.objectives, retained.vector.objectives) : -1; + const provenanceRefresh = compared === 0 && !!retained && JSON.stringify(retained.state.balances) === JSON.stringify(finalized.balances) && retained.state.placements.size === finalized.placements.size && [...retained.state.placements].every(([date, previous]) => { + const placement = finalized.placements.get(date); + return placement && placement.balanceId === previous.balanceId && placement.type === previous.type && placement.supporting === previous.supporting; + }) && JSON.stringify(retained.state.earnedActivations) === JSON.stringify(finalized.earnedActivations); + const chosen = compared < 0 || provenanceRefresh; + if (chosen) { + const firstValidCandidate = !retained; + const candidatePreferences = preferenceResultsForState(finalized, context); + retained = { + state: cloneState(reconcileOptimizationProvenance(finalized, context, retained?.state ?? initialState, vector.objectives)), + vector, + preferences: candidatePreferences, + metrics: finalizedMetrics ? { + ...finalizedMetrics, + preferenceSatisfaction: candidatePreferences.map(({ satisfied }) => satisfied) + } : qualityMetrics(finalized, context, candidatePreferences) + }; + if (firstValidCandidate && checkpointCallback) + observeCallback(context, () => checkpointCallback(makePlan(retained.state, context, retained.preferences, stages), planningStateSnapshot(retained.state, context))); + } + } finally { + context.check = check; + } + }; + const finish = (outcome, explanationState) => { + outcome.calculationDate = source.calculationDate; + const planningFinishedAt = now(); + const planningExcludedTime = context.observerTime; + outcomeCallback?.(outcome); + context.check = () => { + return; + }; + let explanation; + let explanationError; + let finalState; + try { + if (outcome.kind === "Plan" && explanationState) + explanation = planExplanation(explanationState, context, outcome.placements, outcome.timeOffPeriods, outcome.preferences, outcome.remainingBalances, outcome.metrics); + else if (outcome.kind === "PlanningIncomplete") { + if (outcome.incumbent && explanationState) { + const incumbentExplanation = planExplanation(explanationState, context, outcome.incumbent.placements, outcome.incumbent.timeOffPeriods, outcome.incumbent.preferences, outcome.incumbent.remainingBalances, outcome.incumbent.metrics); + explanation = { + ...incumbentExplanation, + incomplete: { + kind: "IncompleteExplanation", + reason: outcome.reasons[0], + completedStages: [...outcome.completedStages], + uncompletedStages: planningStages(context).filter((stage) => !outcome.completedStages.includes(stage)) + } + }; + } else + explanation = { + kind: "IncompleteExplanation", + reason: outcome.reasons[0], + completedStages: [...outcome.completedStages], + uncompletedStages: planningStages(context).filter((stage) => !outcome.completedStages.includes(stage)) + }; + } else if (outcome.kind === "PlanningConflict") + explanation = conflictExplanation(outcome, context); + } catch (error) { + explanationError = reason("explanation-error", error instanceof Error ? error.message : String(error)); + } + try { + if (explanationState) + finalState = planningStateSnapshot(explanationState, context); + } catch (error) { + explanationError ??= reason("explanation-error", error instanceof Error ? error.message : String(error)); + } + if (explanation) + explanation.calculationDate = source.calculationDate; + try { + explanationCallback?.(explanation, explanationError, finalState); + } catch (error) { + explanationError ??= reason("explanation-error", error instanceof Error ? error.message : String(error)); + } + artifactsCallback?.({ + explanation, + finalState, + explanationError, + timings: { + planningWorkMs: Math.max(0, planningFinishedAt - started - planningExcludedTime) + } + }); + return outcome; + }; + const publishCheckpoint = () => { + state = reconcileOptimizationProvenance(state, context, checkpointState ?? initialState); + context.retain?.(state); + checkpoint = makePlan(state, context, preferences, stages); + checkpointState = cloneState(state); + if (checkpointCallback) { + observeCallback(context, () => checkpointCallback(checkpoint, planningStateSnapshot(checkpointState, context))); + } + }; + try { + markProgress(context, "validation"); + yield true; + if (context.declaredDays.length) { + for (const rule of context.rules) { + if (!rule.enabled || rule.type !== "if_then" || !rule.rewardDays) + continue; + for (const occurrence of occurrences(rule, context)) { + yield; + const pattern = patternsFor(rule, occurrence, context).find((dates) => dates.every((date) => declaredPatternDay(date, rule.placeType, context))); + if (pattern) + state = addEarnedBalance(state, rule, occurrence, pattern, context); + } + } + if (state !== initialState) + context.retain?.(state); + } + context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context); + markProgress(context, "obligations"); + yield true; + const obligationRequirements = buildRequirements(normalized.rules, context, "obligation"); + context.reservedTypes = new Set(obligationRequirements.filter((requirement) => requirement.requiredPlacements > 0).map((requirement) => requirement.rule.placeType).filter((selector) => selector !== "any")); + for (const requirement of obligationRequirements) { + yield; + if (!requirement.candidates || !requirement.requiredPlacements || requirement.candidates.length !== 1 || requirement.rule.placeType === "any") + continue; + const types = context.sharedTypesByDate.get(requirement.candidates[0]) ?? new Set; + types.add(requirement.rule.placeType); + context.sharedTypesByDate.set(requirement.candidates[0], types); + } + const obligationSolutions = []; + const optionalProducers = obligationRequirements.some((requirement) => requirement.requiredPlacements > 0) ? buildRequirements(normalized.rules, context, "preference").filter((requirement) => requirement.patterns?.length && requirement.rule.rewardDays && requirement.requiredPlacements > 0).map((requirement) => ({ ...requirement, optional: true })) : []; + const jointRequirements = optionalProducers.length ? [...obligationRequirements, ...optionalProducers].sort(compareRequirements) : obligationRequirements; + const hasEarnedObligation = optionalProducers.length > 0 || obligationRequirements.some((requirement) => (requirement.rule.rewardDays ?? 0) > 0); + const obligationSolutionLimit = hasEarnedObligation || obligationBranchEstimate(obligationRequirements) <= EXACT_OBLIGATION_BRANCH_LIMIT ? 0 : OBLIGATION_SOLUTION_LIMIT; + yield* satisfyObligations(jointRequirements, state, context, 0, obligationSolutions, obligationSolutionLimit); + if (!obligationSolutions.length) { + stages.push("obligations"); + return finish(obligationConflict(obligationRequirements, context)); + } + 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) { + const ranked = []; + for (const candidate of obligationSolutions) { + yield; + context.check(); + ranked.push({ + state: candidate, + objectives: obligationPreviewObjectives({ state: candidate }, context) + }); + } + ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives)); + previewSolutions = ranked.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT).map(({ state: state2 }) => state2); + } + const soleSolution = obligationSolutions.length === 1 && (obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit) ? obligationSolutions[0] : undefined; + const solePreferences = soleSolution ? preferenceResultsForState(soleSolution, context) : []; + let bestPreview = 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 = bestPreview ? compareObligationPreviews(candidate, bestPreview, context) : undefined; + if (!bestPreview || compared < 0) + bestPreview = candidate; + } + if (!bestPreview) { + stages.push("obligations"); + return finish(obligationConflict(obligationRequirements, context)); + } + state = bestPreview.obligationState; + 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(); + } + 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); + } catch (error) { + if (error instanceof UserCancelled && retained) { + context.check = () => { + return; + }; + context.retain = undefined; + checkpointState = retained.state; + const partialPreferences = retained.preferences.map((preference) => ({ + ...preference, + reason: preferenceReason(normalized.rules.find((rule) => rule.type !== "optimization" && rule.id === preference.ruleId), preference.satisfied, context, retained.state) + })); + checkpoint = makePlan(checkpointState, context, partialPreferences, stages); + } + const interruption = error instanceof UserCancelled ? reason("user-cancelled", "The calculation was cancelled by the user.") : error instanceof DeadlineReached ? reason("deadline", `Planning stopped after ${context.deadline}ms before all quality stages completed.`) : reason("planner-error", error instanceof Error ? error.message : String(error)); + return finish({ + kind: "PlanningIncomplete", + incumbent: checkpoint, + completedStages: [...stages], + lastCompletedStage: stages[stages.length - 1], + reasons: [interruption] + }, checkpointState); + } +} +function explainIncompleteCheckpoint(request, incumbent, checkpoint, interruption) { + try { + const source = runtimeRequest(request, incumbent.calculationDate ?? checkpoint.calculationDate); + const normalized = normalizeRequest(source); + if (normalized.reasons.length || !normalized.horizon) + throw new Error("The checkpoint explanation request is invalid."); + const horizon = normalized.horizon; + const holidays = holidaySet(horizon); + const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400)); + const calendarIndex = new Map(calendarDays.map((date, index) => [date, index])); + const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays))); + const workingPrefix = [0]; + for (const date of calendarDays) + workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date))); + const context = { + horizon, + holidayWeeks: [], + holidays, + calendarDays, + calendarIndex, + workingCalendar, + workingPrefix, + workingDays: dateRange(horizon.start, horizon.end).filter((date) => isWorking(date, holidays)), + declaredDays: normalized.declaredDays, + declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)), + initialBalances: normalized.balances, + rules: normalized.rules, + deadline: Number.POSITIVE_INFINITY, + started: 0, + request: source, + nodes: 0, + check: () => { + return; + }, + reservedTypes: new Set, + sharedTypesByDate: new Map, + protectedTypesByDate: new Map, + preferenceDemandByDate: new Map, + anyPreferenceDemandByDate: new Map, + observerTime: 0, + completedPreferenceStates: new Map + }; + context.holidayWeeks = holidayWeekCandidates(context); + const state = { + balances: checkpoint.balances.map((balance) => ({ ...balance })), + placements: new Map(checkpoint.placements.map((placement) => [ + placement.date, + { ...placement, ruleIds: new Set(placement.ruleIds) } + ])), + earnedActivations: checkpoint.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates] + })) + }; + const explanation = planExplanation(state, context, incumbent.placements, incumbent.timeOffPeriods, incumbent.preferences, incumbent.remainingBalances, incumbent.metrics); + const uncompletedStages = planningStages(context).filter((stage) => !incumbent.completedStages.includes(stage)); + return { + explanation: uncompletedStages.length === 0 ? explanation : { + ...explanation, + incomplete: { + kind: "IncompleteExplanation", + reason: interruption, + completedStages: [...incumbent.completedStages], + uncompletedStages + } + }, + finalState: planningStateSnapshot(state, context) + }; + } catch (error) { + return { + explanationError: reason("explanation-error", error instanceof Error ? error.message : String(error)) + }; + } +} +function counterfactualReason(code, message) { + return { code, message }; +} +function counterfactualContext(source, normalized, horizon) { + const holidays = holidaySet(horizon); + const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400)); + const calendarIndex = new Map(calendarDays.map((date, index) => [date, index])); + const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays))); + const workingPrefix = [0]; + for (const date of calendarDays) + workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date))); + const context = { + horizon, + holidayWeeks: [], + holidays, + calendarDays, + calendarIndex, + workingCalendar, + workingPrefix, + workingDays: dateRange(horizon.start, horizon.end).filter((date) => isWorking(date, holidays)), + declaredDays: normalized.declaredDays, + declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)), + initialBalances: normalized.balances, + rules: normalized.rules, + deadline: Number.POSITIVE_INFINITY, + started: 0, + request: source, + nodes: 0, + check: () => { + return; + }, + reservedTypes: new Set, + sharedTypesByDate: new Map, + protectedTypesByDate: new Map, + preferenceDemandByDate: new Map, + anyPreferenceDemandByDate: new Map, + observerTime: 0, + completedPreferenceStates: new Map + }; + context.holidayWeeks = holidayWeekCandidates(context); + return context; +} +function stateFromPlanningSnapshot(state) { + return { + balances: state.balances.map((balance) => ({ ...balance })), + placements: new Map(state.placements.map((placement) => [ + placement.date, + { ...placement, ruleIds: new Set(placement.ruleIds) } + ])), + earnedActivations: state.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates] + })) + }; +} +function sameObjectiveValue(a, b) { + return a === b; +} +function objectiveComparison(before, after) { + const delta = after.objectives.map((objective, index) => { + const previous = before.objectives[index]; + const value = typeof objective.value === "number" && typeof previous?.value === "number" ? objective.value - previous.value : sameObjectiveValue(objective.value, previous?.value ?? objective.value) ? 0 : null; + return { + id: objective.id, + direction: objective.direction, + value, + label: objective.label + }; + }); + const first = firstDifferentObjective(after.objectives, before.objectives); + const decisive = first >= 0 ? after.objectives[first].id : undefined; + return { + before, + after, + delta, + firstDecisiveObjective: decisive, + tieBreak: decisive === "balance-expiry" ? "balance-expiry" : decisive === "placement-date" ? "placement-date" : undefined + }; +} +function counterfactualRuleValid(date, type, ruleIds, context, baseline) { + for (const ruleId of ruleIds) { + const rule = context.rules.find((candidate) => candidate.id === ruleId); + if (!rule) + return false; + if (rule.type === "optimization") + continue; + if (rule.type === "time_window") { + if (!selectorMatches(rule.placeType, type) || compareDate(date, rule.startDate) < 0 || compareDate(date, rule.endDate) > 0) + return false; + continue; + } + const matchesPattern = occurrences(rule, context).some((occurrence) => patternsFor(rule, occurrence, context).some((pattern) => pattern.includes(date) && pattern.every((candidateDate) => candidateDate === date || patternDateSatisfied(candidateDate, rule, baseline, context)))); + if (!matchesPattern || !selectorMatches(rule.placeType, type)) + return false; + } + return true; +} +function counterfactualObligationsValid(state, context, previous) { + return context.rules.filter((rule) => rule.enabled && rule.force === "obligation").every((rule) => { + if (rule.type === "time_window") { + const count = (candidate) => [...candidate.placements.values()].filter((placement) => placement.ruleIds.has(rule.id)).length; + const requiredPlacements = rule.placeDays - declaredWindowDays(rule, context).length; + return count(previous) !== requiredPlacements || count(state) === requiredPlacements; + } + return occurrences(rule, context).every((occurrence) => { + const patterns = patternsFor(rule, occurrence, context); + const complete = (candidate) => patterns.some((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, candidate, context))); + return !complete(previous) || complete(state); + }); + }); +} +function preferenceResultsForState(state, context) { + let patternsByRule = preferencePatternCache.get(context); + if (!patternsByRule) { + patternsByRule = new Map(context.rules.filter((rule) => rule.enabled && rule.force === "preference" && rule.type === "if_then").map((rule) => [ + rule.id, + occurrences(rule, context).map((occurrence) => patternsFor(rule, occurrence, context)) + ])); + preferencePatternCache.set(context, patternsByRule); + } + return context.rules.filter((rule) => rule.enabled && rule.force === "preference" && rule.type !== "optimization").map((rule) => { + const occurrencePatterns = patternsByRule.get(rule.id) ?? []; + const requested = rule.type === "time_window" ? rule.placeDays : occurrencePatterns.length; + const satisfied = rule.type === "time_window" ? Math.min(rule.placeDays, declaredWindowDays(rule, context).length + [...state.placements.values()].filter((placement) => placement.date >= rule.startDate && placement.date <= rule.endDate && selectorMatches(rule.placeType, placement.type)).length) : occurrencePatterns.filter((patterns) => patterns.some((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, state, context)))).length; + return { + ruleId: rule.id, + requested, + satisfied, + completeOccurrences: rule.type === "if_then" ? satisfied : 0 + }; + }); +} +function analyzeCounterfactualInternal(request, plan, selected, finalStateSnapshot) { + const source = runtimeRequest(request, plan.calculationDate ?? finalStateSnapshot?.calculationDate); + const normalized = normalizeRequest(source); + const horizon = normalized.horizon ?? plan.horizon; + const context = counterfactualContext(source, normalized, horizon); + const finalState = finalStateSnapshot ? stateFromPlanningSnapshot(finalStateSnapshot) : (() => { + const balances = normalized.balances.map((balance) => ({ + ...balance, + remaining: balance.quantity + })); + for (const placement of plan.placements) { + let balance = balances.find((candidate) => candidate.id === placement.balanceId); + if (!balance) { + const remaining = plan.remainingBalances.find((candidate) => candidate.id === placement.balanceId); + balance = { + id: placement.balanceId, + type: placement.type, + quantity: 0, + remaining: 0, + availableFrom: remaining?.availableFrom ?? horizon.start, + expiryDate: remaining?.expiryDate ?? horizon.end, + earned: remaining?.earned ?? true, + sourceRuleId: remaining?.sourceRuleId + }; + balances.push(balance); + } + balance.remaining = Math.max(0, balance.remaining - 1); + } + return { + balances, + placements: new Map(plan.placements.map((placement) => [ + placement.date, + { ...placement, ruleIds: new Set(placement.ruleIds) } + ])), + earnedActivations: [] + }; + })(); + const comparisonState = cloneState(finalState); + const baseline = cloneState(finalState); + const removeDate = selected.date; + const removed = removeDate ? baseline.placements.get(removeDate) : undefined; + if (removeDate) + baseline.placements.delete(removeDate); + const released = baseline.balances.find((balance) => balance.id === selected.balanceId); + if (released && removed?.balanceId === selected.balanceId) + released.remaining++; + const beforeState = baseline; + const before = objectiveVector(beforeState, context); + const results = []; + for (const date of dateRange(horizon.start, horizon.end)) { + let ineligible; + if (!isWorking(date, context.holidays)) + ineligible = counterfactualReason("non-working", "The date is a weekend or public holiday."); + else if (context.declaredDates.has(date)) + ineligible = counterfactualReason("declared-day", "The date is a declared non-working day fixed by the user."); + else if (baseline.placements.has(date)) + ineligible = counterfactualReason("occupied", "Another final placement already uses this date."); + else if (!released) + ineligible = counterfactualReason("unavailable", "The selected leave entitlement is unavailable."); + else if (compareDate(released.availableFrom, date) > 0) + ineligible = counterfactualReason("unavailable", "The selected entitlement is not available yet."); + else if (compareDate(date, released.expiryDate) > 0) + ineligible = counterfactualReason("expired", "The selected entitlement has expired by this date."); + else if (released.type !== selected.type) + ineligible = counterfactualReason("type-incompatible", "The date cannot host this leave type."); + else if (!counterfactualRuleValid(date, selected.type, selected.ruleIds, context, baseline)) + ineligible = counterfactualReason("rule-invalid", "The date does not preserve the placement rules."); + if (ineligible) { + results.push({ date, status: "ineligible", reason: ineligible }); + continue; + } + const candidate = assignDateToBalance(baseline, date, selected.balanceId, context, new Set(selected.ruleIds)); + if (!candidate) { + results.push({ + date, + status: "ineligible", + reason: counterfactualReason("unavailable", "The selected entitlement cannot be placed on this date.") + }); + continue; + } + candidate.placements.get(date).supporting = selected.supporting; + 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); + } + const finalized = finalizeEarnedState(candidate, context); + if (!finalized) { + results.push({ + date, + status: "ineligible", + reason: counterfactualReason("unavailable", "This date would make generated leave unavailable until its producing period ends.") + }); + continue; + } + if (!counterfactualObligationsValid(finalized, context, comparisonState)) { + results.push({ + date, + status: "ineligible", + reason: counterfactualReason("rule-invalid", "The date would leave an obligation unsatisfied.") + }); + continue; + } + const after = objectiveVector(finalized, context); + const comparison = objectiveComparison(before, after); + results.push({ date, status: "eligible", comparison }); + } + return { + kind: "Counterfactual", + calculationId: "", + placement: { ...selected, ruleIds: [...selected.ruleIds] }, + hypothetical: true, + baseline: before, + results + }; +} +export { + invalidInputFor, + explainIncompleteCheckpoint, + calculatePlanningInternal, + calculatePlanningAsyncInternal, + analyzeCounterfactualInternal +}; diff --git a/src/app/services/frozen-engine/leave-planner.core.frozen.mjs b/src/app/services/frozen-engine/leave-planner.core.frozen.mjs index 54ef602..7ac350d 100644 --- a/src/app/services/frozen-engine/leave-planner.core.frozen.mjs +++ b/src/app/services/frozen-engine/leave-planner.core.frozen.mjs @@ -195,10 +195,7 @@ var OPTIMIZATION_PRESETS = { } }; var nonWorkingDateCounts = new WeakMap; -function evaluateOptimization(configuration, observations) { - const validation = validateOptimizationConfiguration(configuration); - if (!validation.valid) - throw new Error(`${validation.code}: ${validation.message}`); +function evaluateValidatedOptimization(configuration, observations) { const selected = (condition) => configuration.conditions.includes(condition); let values; switch (configuration.elements) { @@ -248,14 +245,445 @@ function compareObjectiveLists(left, right) { return index < 0 ? 0 : compareObjectiveValues(left[index].direction, left[index].value, right[index].value); } +// src/app/services/plan-observations.ts +var DAY_MS = 86400000; +function utcDate(key) { + return new Date(`${key}T00:00:00Z`); +} +function addDays(key, days) { + return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10); +} +function compareDate(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} +function dateRange(start, endExclusive) { + const result = []; + for (let date = start;compareDate(date, endExclusive) < 0; date = addDays(date, 1)) + result.push(date); + return result; +} +var calendarPeriodCache = new WeakMap; +var occupancies = new WeakMap; +var derivations = new WeakMap; +var shadowMode = globalThis.process?.env?.["VAC_OBSERVATION_SHADOW"] === "1"; +function note(parent, child, op, date) { + occupancies.delete(child); + if (parent === child) + return; + derivations.set(child, { parent, op, date }); +} +function derivePlacements(parent, child) { + note(parent, child, "same"); +} +function notePlacementAdded(parent, child, date) { + note(parent, child, "add", date); +} +function notePlacementRemoved(parent, child, date) { + note(parent, child, "remove", date); +} +function insertionPoint(sorted, date) { + let low = 0; + let high = sorted.length; + while (low < high) { + const mid = low + high >> 1; + if (sorted[mid] < date) + low = mid + 1; + else + high = mid; + } + return low; +} +function occupancyFromScratch(placements, context) { + const dates = [...placements.keys()].sort(); + return { calendar: context, dates, groups: groupsFromScratch(dates, context), observed: {} }; +} +function deriveOccupancy(parent, { op, date }, context) { + if (op === "same") + return parent; + const at = insertionPoint(parent.dates, date); + const present = parent.dates[at] === date; + if (present === (op === "add")) + return; + const dates = parent.dates.slice(); + if (op === "add") + dates.splice(at, 0, date); + else + dates.splice(at, 1); + const touchesGroups = date >= context.horizon.start && date < context.horizon.end && !context.declaredDates.has(date); + if (touchesGroups && !context.workingCalendar.has(date)) + return; + const groups = !touchesGroups ? parent.groups : op === "add" ? groupsWithDate(parent.groups, date, context) : groupsWithoutDate(parent.groups, date, context); + if (!groups) + return; + const observed = {}; + if (parent.observed.publicHolidayWeeks) + observed.publicHolidayWeeks = derivedHolidayWeeks(parent.observed.publicHolidayWeeks, dates, date, context); + return { calendar: context, dates, groups, observed }; +} +var weeksByDate = new WeakMap; +function derivedHolidayWeeks(weeks, dates, date, context) { + let index = weeksByDate.get(context); + if (!index) { + index = new Map; + context.holidayWeeks.forEach((week, i) => week.dates.forEach((d) => { + const list = index.get(d); + if (list) + list.push(i); + else + index.set(d, [i]); + })); + weeksByDate.set(context, index); + } + const affected = index.get(date); + if (!affected) + return weeks; + const next = weeks.slice(); + for (const i of affected) { + const week = context.holidayWeeks[i]; + const complete = week.dates.every((d) => { + const at = insertionPoint(dates, d); + return dates[at] === d || context.declaredDates.has(d); + }); + if (complete !== weeks[i].complete) + next[i] = Object.freeze({ value: week.value, complete }); + } + return Object.freeze(next); +} +function groupsWithDate(groups, date, context) { + let low = 0; + let high = groups.length; + while (low < high) { + const mid = low + high >> 1; + if (groups[mid].anchors[0] < date) + low = mid + 1; + else + high = mid; + } + const previous = low > 0 ? groups[low - 1] : undefined; + const next = low < groups.length ? groups[low] : undefined; + const index = context.calendarIndex.get(date); + const joinsPrevious = !!previous && context.workingPrefix[index] - context.workingPrefix[context.calendarIndex.get(previous.anchors[previous.anchors.length - 1]) + 1] === 0; + const joinsNext = !!next && context.workingPrefix[context.calendarIndex.get(next.anchors[0])] - context.workingPrefix[index + 1] === 0; + const merged = { + anchors: [ + ...joinsPrevious ? previous.anchors : [], + date, + ...joinsNext ? next.anchors : [] + ], + startIndex: joinsPrevious ? previous.startIndex : extendStart(index, context), + endIndex: joinsNext ? next.endIndex : extendEnd(index, context) + }; + const from = joinsPrevious ? low - 1 : low; + return [...groups.slice(0, from), merged, ...groups.slice(joinsNext ? low + 1 : low)]; +} +function groupsWithoutDate(groups, date, context) { + let low = 0; + let high = groups.length; + while (low < high) { + const mid = low + high >> 1; + if (groups[mid].anchors[0] <= date) + low = mid + 1; + else + high = mid; + } + const group = groups[low - 1]; + if (!group) + return; + const cut = insertionPoint(group.anchors, date); + if (group.anchors[cut] !== date) + return; + const leftAnchors = group.anchors.slice(0, cut); + const rightAnchors = group.anchors.slice(cut + 1); + const split = []; + if (leftAnchors.length) + split.push({ + anchors: leftAnchors, + startIndex: group.startIndex, + endIndex: extendEnd(context.calendarIndex.get(leftAnchors[leftAnchors.length - 1]), context) + }); + if (rightAnchors.length) + split.push({ + anchors: rightAnchors, + startIndex: extendStart(context.calendarIndex.get(rightAnchors[0]), context), + endIndex: group.endIndex + }); + return [...groups.slice(0, low - 1), ...split, ...groups.slice(low)]; +} +function occupancyOf(placements, context) { + const chain = []; + let current = placements; + let occupancy; + for (;; ) { + const known = occupancies.get(current); + if (known?.calendar === context) { + occupancy = known; + break; + } + const derivation = derivations.get(current); + if (!derivation) + break; + chain.push([current, derivation]); + current = derivation.parent; + } + if (!occupancy) { + occupancy = occupancyFromScratch(current, context); + occupancies.set(current, occupancy); + } + for (let i = chain.length - 1;i >= 0; i--) { + const [map, derivation] = chain[i]; + derivations.delete(map); + occupancy = deriveOccupancy(occupancy, derivation, context) ?? occupancyFromScratch(map, context); + occupancies.set(map, occupancy); + } + return occupancy; +} +function tieKeyOf(occupancy) { + return occupancy.tieKey ??= occupancy.dates.join("|"); +} +function placementTieKey(state, context) { + const occupancy = occupancyOf(state.placements, context); + if (shadowMode) + verifyOccupancy(state, context, occupancy); + return tieKeyOf(occupancy); +} +function expiryTieKey(state) { + return [...state.placements.values()].map((placement) => state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? "").sort(compareDate).join("|"); +} +function isAtRisk(balance, context) { + return compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0; +} +function isNaturalNonWorkingDate(date, context) { + return context.calendarIndex.has(date) ? !context.workingCalendar.has(date) : !isWorkingPlanningDate(date); +} +function calendarDateAt(index, context) { + return context.calendarDays[index] ?? addDays(context.calendarDays[0], index); +} +function extendStart(index, context) { + while (context.declaredDates.has(calendarDateAt(index - 1, context)) || isNaturalNonWorkingDate(calendarDateAt(index - 1, context), context)) { + context.check(); + index--; + } + return index; +} +function extendEnd(index, context) { + while (context.declaredDates.has(calendarDateAt(index + 1, context)) || isNaturalNonWorkingDate(calendarDateAt(index + 1, context), context)) { + context.check(); + index++; + } + return index; +} +function groupsFromScratch(placementDates, context) { + const dates = [...new Set([...placementDates, ...context.declaredDates])].filter((date) => date >= context.horizon.start && date < context.horizon.end).sort(compareDate); + if (!dates.length) + return []; + const groups = []; + let group = [dates[0]]; + for (let i = 1;i < dates.length; i++) { + context.check(); + const previousIndex = context.calendarIndex.get(dates[i - 1]); + const currentIndex = context.calendarIndex.get(dates[i]); + const workingInGap = context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1]; + if (workingInGap === 0) + group.push(dates[i]); + else { + groups.push(group); + group = [dates[i]]; + } + } + groups.push(group); + return groups.map((anchors) => { + context.check(); + return { + anchors, + startIndex: extendStart(context.calendarIndex.get(anchors[0]), context), + endIndex: extendEnd(context.calendarIndex.get(anchors[anchors.length - 1]), context) + }; + }); +} +function calendarPeriod(startIndex, endIndex, context) { + let periods = calendarPeriodCache.get(context); + if (!periods) { + periods = new Map; + calendarPeriodCache.set(context, periods); + } + const key = startIndex * 1048576 + endIndex; + let period = periods.get(key); + if (period) + periods.delete(key); + else { + const start = calendarDateAt(startIndex, context); + const end = calendarDateAt(endIndex, context); + const dates = startIndex >= 0 && endIndex < context.calendarDays.length ? context.calendarDays.slice(startIndex, endIndex + 1) : dateRange(start, addDays(end, 1)); + period = Object.freeze({ + start, + end, + duration: dates.length, + nonWorkingDates: Object.freeze(dates.filter((date) => isNaturalNonWorkingDate(date, context))), + declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))) + }); + } + periods.set(key, period); + if (periods.size > 4096) + periods.delete(periods.keys().next().value); + return period; +} +function periodsOf(state, groups, context) { + return groups.map((group) => { + const period = group.period ??= calendarPeriod(group.startIndex, group.endIndex, context); + return { + start: period.start, + end: period.end, + duration: period.duration, + placementDates: group.anchors.filter((date) => state.placements.has(date)), + ...period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}, + nonWorkingDates: [...period.nonWorkingDates], + nonWorkingDayGain: period.nonWorkingDates.length + }; + }); +} +function periodData(state, context) { + const occupancy = occupancyOf(state.placements, context); + if (shadowMode) + verifyOccupancy(state, context, occupancy); + return periodsOf(state, occupancy.groups, context); +} +function observationsFromScratch(state, context) { + const dates = [...state.placements.keys()].sort(); + const groups = groupsFromScratch(dates, context); + return { + tieKey: dates.join("|"), + groups: groups.map(({ anchors, startIndex, endIndex }) => ({ + anchors, + startIndex, + endIndex + })), + periods: periodsOf(state, groups, context), + ...calendarObservations(state, groups, context) + }; +} +function holidayWeeksFor(state, context) { + return Object.freeze(context.holidayWeeks.map((week) => Object.freeze({ + value: week.value, + complete: week.dates.every((date) => state.placements.has(date) || context.declaredDates.has(date)) + }))); +} +function timeOffPeriodsFor(groups, context, periods) { + return Object.freeze(periods ? periods.map((period) => Object.freeze({ nonWorkingDates: Object.freeze([...period.nonWorkingDates]) })) : groups.map((group) => group.timeOff ??= Object.freeze({ + nonWorkingDates: (group.period ??= calendarPeriod(group.startIndex, group.endIndex, context)).nonWorkingDates + }))); +} +function workSequencesFor(groups, context) { + return Object.freeze(workSequenceLengths(groups, context).map((sequence) => Object.freeze(sequence))); +} +function calendarObservations(state, groups, context) { + return { + publicHolidayWeeks: holidayWeeksFor(state, context), + timeOffPeriods: timeOffPeriodsFor(groups, context), + workSequences: workSequencesFor(groups, context) + }; +} +function verifyOccupancy(state, context, occupancy) { + const expected = observationsFromScratch(state, context); + const fail = (field, got, want) => { + throw new Error(`Observation shadow mismatch on ${field} for placements [${[...state.placements.keys()].sort().join(", ")}]: got ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`); + }; + if (tieKeyOf(occupancy) !== expected.tieKey) + fail("tieKey", tieKeyOf(occupancy), expected.tieKey); + const groups = occupancy.groups.map(({ anchors, startIndex, endIndex }) => ({ + anchors, + startIndex, + endIndex + })); + if (JSON.stringify(groups) !== JSON.stringify(expected.groups)) + fail("groups", groups, expected.groups); + for (const field of ["publicHolidayWeeks", "timeOffPeriods", "workSequences"]) { + const cached = occupancy.observed[field]; + if (cached && JSON.stringify(cached) !== JSON.stringify(expected[field])) + fail(field, cached, expected[field]); + } +} +function optimizationBalanceObservations(state, context) { + return state.balances.map((balance) => ({ + unplacedQuantity: balance.remaining, + atRisk: isAtRisk(balance, context) + })); +} +function optimizationObservations(state, context, periods) { + const occupancy = occupancyOf(state.placements, context); + const calendar = occupancy.observed; + if (shadowMode) { + verifyOccupancy(state, context, occupancy); + calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context); + calendar.workSequences ??= workSequencesFor(occupancy.groups, context); + if (!periods) + calendar.timeOffPeriods ??= timeOffPeriodsFor(occupancy.groups, context); + verifyOccupancy(state, context, occupancy); + } + return { + leaveBalances: optimizationBalanceObservations(state, context), + get publicHolidayWeeks() { + return calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context); + }, + get timeOffPeriods() { + return calendar.timeOffPeriods ??= timeOffPeriodsFor(occupancy.groups, context, periods); + }, + get workSequences() { + return calendar.workSequences ??= workSequencesFor(occupancy.groups, context); + }, + leavePlacements: [...state.placements.values()] + }; +} +function workSequenceLengths(bounds, context) { + if (!context.workingDays.length) + return []; + let nextIndex = context.calendarIndex.get(context.horizon.start); + const sequences = bounds.map(({ anchors }, index) => { + const previousIndex = nextIndex; + nextIndex = context.calendarIndex.get(anchors[anchors.length - 1]) + 1; + return { + length: context.workingPrefix[context.calendarIndex.get(anchors[0])] - context.workingPrefix[previousIndex], + internal: index > 0 + }; + }); + sequences.push({ + length: context.workingPrefix[context.calendarIndex.get(context.horizon.end)] - context.workingPrefix[nextIndex], + internal: false + }); + return sequences; +} +function workSequenceDates(state, context) { + const sequences = []; + let current = []; + let inTimeOffPeriod = false; + for (const date of context.workingDays) { + context.check(); + if (state.placements.has(date) || context.declaredDates.has(date)) { + if (!inTimeOffPeriod) + sequences.push(current); + current = []; + inTimeOffPeriod = true; + } else { + current.push(date); + inTimeOffPeriod = false; + } + } + if (context.workingDays.length) + sequences.push(current); + return sequences.map((dates, index) => ({ + dates, + internal: index > 0 && index < sequences.length - 1 + })); +} +function evaluateStateOptimization(configuration, state, context, observations = optimizationObservations(state, context)) { + return evaluateValidatedOptimization(configuration, observations); +} + // src/app/services/leave-planner.core.ts var obligationRequirementCache = new WeakMap; var occurrenceCache = new WeakMap; var calendarPatternCache = new WeakMap; var preferencePatternCache = new WeakMap; var declaredWindowCache = new WeakMap; -var calendarObservationCache = new WeakMap; -var calendarPeriodCache = new WeakMap; function now() { return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); } @@ -273,10 +701,8 @@ class DeadlineReached extends Error { class UserCancelled extends Error { } -var DAY_MS = 86400000; var OBLIGATION_SOLUTION_LIMIT = 4096; var EXACT_OBLIGATION_BRANCH_LIMIT = 1e4; -var WINDOW_OBLIGATION_PREVIEW_LIMIT = 8; var VALID_TYPES = new Set(["cp", "rtt", "other"]); var VALID_SELECTORS = new Set(["cp", "rtt", "other", "any"]); var VALID_MODES = new Set(["before", "after", "around", "auto"]); @@ -309,24 +735,9 @@ function runtimeRequest(value, calculationDate) { function recordValue(value) { return typeof value === "object" && value !== null ? value : undefined; } -function utcDate(key) { - return new Date(`${key}T00:00:00Z`); -} -function addDays(key, days) { - return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10); -} -function compareDate(a, b) { - return a < b ? -1 : a > b ? 1 : 0; -} function compareText(a, b) { return a < b ? -1 : a > b ? 1 : 0; } -function dateRange(start, endExclusive) { - const result = []; - for (let date = start;compareDate(date, endExclusive) < 0; date = addDays(date, 1)) - result.push(date); - return result; -} function defaultHorizon(start) { return { start, end: addPlanningYears(start, 1) }; } @@ -367,22 +778,30 @@ function holidayWeekCandidates(context) { function reason(code, message, ruleIds) { return ruleIds?.length ? { code, message, ruleIds } : { code, message }; } -function cloneState(state, placements) { +function copyPlacements(state) { + return new Map([...state.placements.entries()].map(([date, placement]) => [ + date, + { + ...placement, + ruleIds: new Set(placement.ruleIds) + } + ])); +} +function cloneState(state) { + const placements = copyPlacements(state); + derivePlacements(state.placements, placements); return { balances: state.balances.map((balance) => ({ ...balance })), - placements: placements ?? new Map([...state.placements.entries()].map(([date, placement]) => [ - date, - { - ...placement, - ruleIds: new Set(placement.ruleIds) - } - ])), + placements, earnedActivations: state.earnedActivations.map((activation) => ({ ...activation, dates: [...activation.dates] })) }; } +function debitBalance(state, balanceId) { + return state.balances.map((balance) => balance.id === balanceId ? { ...balance, remaining: balance.remaining - 1 } : balance); +} function planningStateSnapshot(state, context) { return { calculationDate: context.request.calculationDate, @@ -855,17 +1274,20 @@ function assignDateVariants(state, date, selector, ruleId, context) { 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--; - 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 + }; }); } function assignDateToBalance(state, date, balanceId, context, ruleIds = new Set) { @@ -881,17 +1303,20 @@ function assignDateToBalance(state, date, balanceId, context, ruleIds = new Set) } if (!mandatoryRelocationDate(date, balance, ruleIds, context)) return null; - const result = cloneState(state, new Map(state.placements)); - const target = result.balances.find((item) => item.id === balanceId); - target.remaining--; - 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(state, rule, occurrence, dates, context) { if (!rule.rewardDays || !rule.rewardType) @@ -955,12 +1380,12 @@ function finalizeEarnedState(state, context) { if (bounds.availableFrom <= context.request.calculationDate) { if ([...result.placements.values()].some((placement) => placement.balanceId === activation.id)) return null; - result.balances = result.balances.filter((balance2) => balance2.id !== activation.id); + result.balances = result.balances.filter((balance) => balance.id !== activation.id); continue; } let balance = result.balances.find((item) => item.id === activation.id); if (!balance) { - const rule = context.rules.find((rule2) => rule2.type === "if_then" && rule2.id === activation.ruleId); + const rule = context.rules.find((rule) => rule.type === "if_then" && rule.id === activation.ruleId); if (!rule) return null; result = addEarnedBalance(result, rule, activation.occurrence, activation.dates, context); @@ -1093,6 +1518,7 @@ function releasePlacement(state, date) { })) }; const placement = result.placements.get(date); + notePlacementRemoved(state.placements, result.placements, date); result.placements.delete(date); result.balances.find((balance) => balance.id === placement.balanceId).remaining++; return result; @@ -1110,19 +1536,21 @@ function reconcileOptimizationProvenance(state, context, before, objectives = ac if (!credits.length) continue; const withoutState = releasePlacement(state, placement.date); - const withoutPlacement = activeObjectiveVector(withoutState, context); + 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}`))); for (const objective of credits) { - const without = withoutPlacement.find(({ id: id2 }) => id2 === objective.id); + const without = withoutPlacement.find(({ id }) => id === objective.id); const id = objective.id.slice("optimization:".length); - const rule = context.rules.find((rule2) => rule2.id === id && rule2.type === "optimization"); + const rule = context.rules.find((rule) => rule.id === id && rule.type === "optimization"); const compared = compareObjectiveValues(objective.direction, objective.value, without.value); let contributes = compared < 0; if (compared === 0 && rule.configuration.aggregation === "maximum") { observations ??= optimizationObservations(state, context); const withoutObservations = optimizationObservations(withoutState, context); const summed = { ...rule.configuration, aggregation: "sum" }; - const summedComparison = compareObjectiveValues(objective.direction, evaluateOptimization(summed, observations), evaluateOptimization(summed, withoutObservations)); + const summedComparison = compareObjectiveValues(objective.direction, evaluateValidatedOptimization(summed, observations), evaluateValidatedOptimization(summed, withoutObservations)); contributes = summedComparison < 0 || summedComparison === 0 && rule.configuration.elements === "time-off-periods" && objective.direction === "maximize" && observations.timeOffPeriods.length < withoutObservations.timeOffPeriods.length; } if (contributes) @@ -1138,7 +1566,7 @@ function reconcileOptimizationProvenance(state, context, before, objectives = ac } function supportingCalendarBenefit(before, after, context) { 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); + return 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); } function mandatoryRelocationDate(date, balance, ruleIds, context) { return [...ruleIds].every((id) => { @@ -1300,7 +1728,7 @@ function typedCalendarDates(context) { } return typedDates; } -function* calendarPlacementCandidates(state, targetDates, ruleId, context, accept) { +function* calendarPlacementCandidates(state, targetDates, ruleId, context, accept, donorObjectives = new Map) { const missing = targetDates.filter((date) => !state.placements.has(date) && !context.declaredDates.has(date)); if (!missing.length) return false; @@ -1313,10 +1741,12 @@ function* calendarPlacementCandidates(state, targetDates, ruleId, context, accep 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 }); } rankedDonors.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives) || compareDate(b.date, a.date)); const fill = function* (index, current, additions = []) { @@ -1338,9 +1768,9 @@ function* calendarPlacementCandidates(state, targetDates, ruleId, context, accep return false; if (index === missing.length) { const benefit = supportingCalendarBenefit(before, current, context); - for (const date2 of additions) { - const placement = { ...current.placements.get(date2) }; - current.placements.set(date2, placement); + for (const date of additions) { + const placement = { ...current.placements.get(date) }; + current.placements.set(date, placement); const balance = current.balances.find((item) => item.id === placement.balanceId); placement.supporting = benefit && !isAtRisk(balance, context); } @@ -1391,8 +1821,9 @@ function* calendarPlacementCandidates(state, targetDates, ruleId, context, accep return yield* fill(0, state); } function* holidayWeekPlacementCandidates(state, ruleId, context, accept) { + const donorObjectives = new Map; for (const week of context.holidayWeeks) - yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept); + yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept, donorObjectives); } function* timeOffPeriodPlacementCandidates(state, ruleId, context, accept) { const periods = periodData(state, context); @@ -1402,9 +1833,48 @@ function* timeOffPeriodPlacementCandidates(state, ruleId, context, accept) { yield* calendarPlacementCandidates(state, dates, ruleId, context, accept); } } +var finalizeInvarianceCache = new WeakMap; +function finalizeInvariance(context) { + let entries = finalizeInvarianceCache.get(context); + if (!entries) { + const earning = context.rules.some((rule) => rule.enabled && rule.type === "if_then" && rule.rewardDays); + entries = context.rules.filter((rule) => rule.enabled && rule.force === "preference").map((rule) => { + if (rule.type !== "optimization") + return "exact"; + switch (rule.configuration.elements) { + case "public-holiday-weeks": + case "time-off-periods": + case "work-sequences": + return "exact"; + case "leave-balances": + return earning ? "unknown" : "without-earning"; + default: + return "unknown"; + } + }); + finalizeInvarianceCache.set(context, entries); + } + return entries; +} +function provablyWorseThanProtectedPrefix(candidate, context, throughIndex, baseline) { + const invariance = finalizeInvariance(context); + let index = 0; + for (const objective of activeObjectives(candidate, context, throughIndex)) { + const kind = invariance[index]; + if (kind === "unknown" || kind === "without-earning" && candidate.earnedActivations.length) + return false; + const comparison = compareObjectiveValues(objective.direction, objective.value, baseline[index].value); + if (comparison !== 0) + return comparison > 0; + index++; + } + return false; +} function evaluateRuleCandidate(before, candidate, context, throughIndex, addition = false, baseline = activeObjectiveVector(before, context, throughIndex), incumbentVector = objectiveVector(before, context)) { if (!candidate) return; + if (provablyWorseThanProtectedPrefix(candidate, context, throughIndex, baseline)) + return; const state = finalizeRuleCandidate(candidate, context, before); if (!state) return; @@ -1426,7 +1896,7 @@ function evaluateRuleCandidate(before, candidate, context, throughIndex, additio } const vector = objectiveVector(state, context, active); if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0) - context.retain?.(state, undefined, vector); + context.retain?.(state, vector); if (addition && comparison === 0) return; return { state, vector }; @@ -1531,7 +2001,7 @@ function* optimizeRulePlacements(state, context, throughIndex, allowAdd, rule) { if (item.type === "time_window") return item.placeDays; if (item.type === "optimization" && item.configuration.elements === "public-holiday-weeks" && 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, @@ -1550,7 +2020,7 @@ function* optimizeRulePlacements(state, context, throughIndex, allowAdd, rule) { 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 && prefixBounds.every((bound, index) => bound !== undefined && active[index].value === bound)) { return result; } @@ -1646,7 +2116,7 @@ function* optimizeRulePlacements(state, context, throughIndex, allowAdd, rule) { const moved = assignDateToBalance(without, destination, existing.balanceId, context, existing.ruleIds); if (moved) moved.placements.get(destination).supporting = existing.supporting; - if (moved && dateInvariantObjectives && compareObjectiveLists(tieBreakObjectives(moved), beforeVector.objectives.slice(-2)) >= 0) { + if (moved && dateInvariantObjectives && compareObjectiveLists(tieBreakObjectives(moved, context), beforeVector.objectives.slice(-2)) >= 0) { continue; } consider(moved); @@ -1848,15 +2318,6 @@ function preferenceReason(rule, count, context, state) { code = "capacity"; return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]); } -function* applyPreferenceRules(state, preferenceRules, context) { - let result = state; - const preferences = []; - for (let preferenceIndex = 0;preferenceIndex < preferenceRules.length; preferenceIndex++) { - yield; - result = yield* applyPreferenceStep(result, preferenceRules, preferenceIndex, preferences, context); - } - return { state: result, preferences }; -} function* applyPreferenceStep(state, preferenceRules, preferenceIndex, preferences, context) { let result = state; const apply = function* (index) { @@ -1901,16 +2362,10 @@ function preferenceStateKey(state) { state.earnedActivations ]); } -function placementTieKey(state) { - return [...state.placements.keys()].sort().join("|"); -} -function expiryTieKey(state) { - return [...state.placements.values()].map((placement) => state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? "").sort(compareDate).join("|"); -} function activeObjectiveVector(state, context, throughIndex = Number.POSITIVE_INFINITY) { return [...activeObjectives(state, context, throughIndex)]; } -function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINITY) { +function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINITY, only) { let preferences; let balances; let calendar; @@ -1920,6 +2375,8 @@ function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINI 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" ? balances ??= { leaveBalances: optimizationBalanceObservations(state, context), @@ -1945,7 +2402,7 @@ function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINI } } } -function tieBreakObjectives(state) { +function tieBreakObjectives(state, context) { return [ { id: "balance-expiry", @@ -1956,186 +2413,20 @@ function tieBreakObjectives(state) { { id: "placement-date", direction: "minimize", - value: placementTieKey(state), + value: placementTieKey(state, context), label: "Placement date tie-break" } ]; } function obligationPreviewObjectives(preview, context) { - return [...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state)]; + return [ + ...activeObjectiveVector(preview.state, context), + ...tieBreakObjectives(preview.state, context) + ]; } function compareObligationPreviews(a, b, context) { return compareObjectiveLists(obligationPreviewObjectives(a, context), obligationPreviewObjectives(b, context)); } -function isAtRisk(balance, context) { - return compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0; -} -function isNaturalNonWorkingDate(date, context) { - return context.calendarIndex.has(date) ? !context.workingCalendar.has(date) : !isWorkingPlanningDate(date); -} -function calendarDateAt(index, context) { - return context.calendarDays[index] ?? addDays(context.calendarDays[0], index); -} -function periodBounds(state, context) { - const dates = [...new Set([...state.placements.keys(), ...context.declaredDates])].filter((date) => date >= context.horizon.start && date < context.horizon.end).sort(compareDate); - if (!dates.length) - return []; - const groups = []; - let group = [dates[0]]; - for (let i = 1;i < dates.length; i++) { - context.check(); - const previousIndex = context.calendarIndex.get(dates[i - 1]); - const currentIndex = context.calendarIndex.get(dates[i]); - const workingInGap = context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1]; - if (workingInGap === 0) - group.push(dates[i]); - else { - groups.push(group); - group = [dates[i]]; - } - } - groups.push(group); - return groups.map((anchors) => { - context.check(); - let startIndex = context.calendarIndex.get(anchors[0]); - let endIndex = context.calendarIndex.get(anchors[anchors.length - 1]); - while (context.declaredDates.has(calendarDateAt(startIndex - 1, context)) || isNaturalNonWorkingDate(calendarDateAt(startIndex - 1, context), context)) { - context.check(); - startIndex--; - } - while (context.declaredDates.has(calendarDateAt(endIndex + 1, context)) || isNaturalNonWorkingDate(calendarDateAt(endIndex + 1, context), context)) { - context.check(); - endIndex++; - } - return { anchors, startIndex, endIndex }; - }); -} -function calendarPeriod(startIndex, endIndex, context) { - let periods = calendarPeriodCache.get(context); - if (!periods) { - periods = new Map; - calendarPeriodCache.set(context, periods); - } - const key = `${startIndex}:${endIndex}`; - let period = periods.get(key); - if (period) - periods.delete(key); - else { - const start = calendarDateAt(startIndex, context); - const end = calendarDateAt(endIndex, context); - const dates = startIndex >= 0 && endIndex < context.calendarDays.length ? context.calendarDays.slice(startIndex, endIndex + 1) : dateRange(start, addDays(end, 1)); - period = Object.freeze({ - start, - end, - duration: dates.length, - nonWorkingDates: Object.freeze(dates.filter((date) => isNaturalNonWorkingDate(date, context))), - declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))) - }); - } - periods.set(key, period); - if (periods.size > 4096) - periods.delete(periods.keys().next().value); - return period; -} -function periodData(state, context) { - return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => { - const period = calendarPeriod(startIndex, endIndex, context); - return { - start: period.start, - end: period.end, - duration: period.duration, - placementDates: anchors.filter((date) => state.placements.has(date)), - ...period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}, - nonWorkingDates: [...period.nonWorkingDates], - nonWorkingDayGain: period.nonWorkingDates.length - }; - }); -} -function optimizationBalanceObservations(state, context) { - return state.balances.map((balance) => ({ - unplacedQuantity: balance.remaining, - atRisk: isAtRisk(balance, context) - })); -} -function optimizationObservations(state, context, periods) { - const key = placementTieKey(state); - let byDates = calendarObservationCache.get(context); - if (!byDates) { - byDates = new Map; - calendarObservationCache.set(context, byDates); - } - const calendar = byDates.get(key) ?? {}; - if (byDates.has(key)) - byDates.delete(key); - else if (byDates.size >= 4096) - byDates.delete(byDates.keys().next().value); - byDates.set(key, calendar); - let bounds; - return { - leaveBalances: optimizationBalanceObservations(state, context), - get publicHolidayWeeks() { - return calendar.publicHolidayWeeks ??= Object.freeze(context.holidayWeeks.map((week) => Object.freeze({ - value: week.value, - complete: week.dates.every((date) => state.placements.has(date) || context.declaredDates.has(date)) - }))); - }, - get timeOffPeriods() { - return calendar.timeOffPeriods ??= Object.freeze(periods ? periods.map((period) => Object.freeze({ - nonWorkingDates: Object.freeze([...period.nonWorkingDates]) - })) : (bounds ??= periodBounds(state, context)).map(({ startIndex, endIndex }) => Object.freeze({ - nonWorkingDates: calendarPeriod(startIndex, endIndex, context).nonWorkingDates - }))); - }, - get workSequences() { - return calendar.workSequences ??= Object.freeze(workSequenceLengths(bounds ??= periodBounds(state, context), context).map((sequence) => Object.freeze(sequence))); - }, - leavePlacements: [...state.placements.values()] - }; -} -function workSequenceLengths(bounds, context) { - if (!context.workingDays.length) - return []; - let nextIndex = context.calendarIndex.get(context.horizon.start); - const sequences = bounds.map(({ anchors }, index) => { - const previousIndex = nextIndex; - nextIndex = context.calendarIndex.get(anchors[anchors.length - 1]) + 1; - return { - length: context.workingPrefix[context.calendarIndex.get(anchors[0])] - context.workingPrefix[previousIndex], - internal: index > 0 - }; - }); - sequences.push({ - length: context.workingPrefix[context.calendarIndex.get(context.horizon.end)] - context.workingPrefix[nextIndex], - internal: false - }); - return sequences; -} -function workSequenceDates(state, context) { - const sequences = []; - let current = []; - let inTimeOffPeriod = false; - for (const date of context.workingDays) { - context.check(); - if (state.placements.has(date) || context.declaredDates.has(date)) { - if (!inTimeOffPeriod) - sequences.push(current); - current = []; - inTimeOffPeriod = true; - } else { - current.push(date); - inTimeOffPeriod = false; - } - } - if (context.workingDays.length) - sequences.push(current); - return sequences.map((dates, index) => ({ - dates, - internal: index > 0 && index < sequences.length - 1 - })); -} -function evaluateStateOptimization(configuration, state, context, observations = optimizationObservations(state, context)) { - return evaluateOptimization(configuration, observations); -} function qualityMetrics(state, context, preferences) { const periods = periodData(state, context); const observations = optimizationObservations(state, context, periods); @@ -2219,7 +2510,7 @@ function objectiveVector(state, context, active = activeObjectiveVector(state, c label: "Unsatisfied obligations" }, ...active, - ...tieBreakObjectives(state) + ...tieBreakObjectives(state, context) ] }; } @@ -2309,6 +2600,7 @@ function planExplanation(state, context, placements, periods, preferences, remai }); const atRisk = normalizedBalance ? compareDate(normalizedBalance.expiryDate, context.horizon.start) >= 0 && compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0 : false; const withoutPlacement = cloneState(state); + notePlacementRemoved(state.placements, withoutPlacement.placements, placement.date); withoutPlacement.placements.delete(placement.date); const released = withoutPlacement.balances.find((candidate) => candidate.id === placement.balanceId); if (released) @@ -2502,16 +2794,30 @@ function calculatePlanningInternal(request, checkpointCallback, artifactsCallbac } async function calculatePlanningAsyncInternal(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, isCancelled = () => false) { const steps = calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, true); - let next = steps.next(); - let pauseAt = now() + 8; - while (!next.done) { - if (next.value || now() >= pauseAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - pauseAt = now() + 8; + const channel = typeof setImmediate === "function" ? undefined : new MessageChannel; + let resume = () => {}; + if (channel) + channel.port1.onmessage = () => resume(); + const pause = () => new Promise((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(horizon, progress) { const start = addDays(horizon.start, -400); @@ -2639,13 +2945,13 @@ function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, let checkpointState; let retained; if (retainIncumbents) - context.retain = (candidate, finalizedMetrics, candidateVector) => { + context.retain = (candidate, candidateVector) => { const check = context.check; context.check = () => { return; }; try { - const finalized = finalizedMetrics || candidateVector ? candidate : finalizeEarnedState(candidate, context); + const finalized = candidateVector ? candidate : finalizeEarnedState(candidate, context); if (!finalized) return; if (unsatisfiedObligationCount(finalized, context)) @@ -2665,11 +2971,7 @@ function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, retained = { state: cloneState(reconcileOptimizationProvenance(finalized, context, retained?.state ?? initialState, vector.objectives)), vector, - preferences: candidatePreferences, - metrics: finalizedMetrics ? { - ...finalizedMetrics, - preferenceSatisfaction: candidatePreferences.map(({ satisfied }) => satisfied) - } : qualityMetrics(finalized, context, candidatePreferences) + preferences: candidatePreferences }; if (firstValidCandidate && checkpointCallback) observeCallback(context, () => checkpointCallback(makePlan(retained.state, context, retained.preferences, stages), planningStateSnapshot(retained.state, context))); @@ -2789,8 +3091,8 @@ function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, return finish(obligationConflict(obligationRequirements, context)); } 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) { + let rankedSolutions = obligationSolutions; + if (obligationSolutions.length > 1) { const ranked = []; for (const candidate of obligationSolutions) { yield; @@ -2801,72 +3103,48 @@ function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, }); } ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives)); - previewSolutions = ranked.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT).map(({ state: state2 }) => state2); + rankedSolutions = ranked.map(({ state }) => state); } - const soleSolution = obligationSolutions.length === 1 && (obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit) ? obligationSolutions[0] : undefined; - const solePreferences = soleSolution ? preferenceResultsForState(soleSolution, context) : []; - let bestPreview = 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 = bestPreview ? compareObligationPreviews(candidate, bestPreview, context) : undefined; - if (!bestPreview || compared < 0) - bestPreview = candidate; - } - if (!bestPreview) { + for (const obligationSolution of rankedSolutions) { + 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); + 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) { context.check = () => { @@ -3137,8 +3415,10 @@ function analyzeCounterfactualInternal(request, plan, selected, finalStateSnapsh const baseline = cloneState(finalState); const removeDate = selected.date; const removed = removeDate ? baseline.placements.get(removeDate) : undefined; - if (removeDate) + if (removeDate) { + notePlacementRemoved(finalState.placements, baseline.placements, removeDate); baseline.placements.delete(removeDate); + } const released = baseline.balances.find((balance) => balance.id === selected.balanceId); if (released && removed?.balanceId === selected.balanceId) released.remaining++; @@ -3214,9 +3494,9 @@ function analyzeCounterfactualInternal(request, plan, selected, finalStateSnapsh }; } export { - invalidInputFor, - explainIncompleteCheckpoint, - calculatePlanningInternal, + analyzeCounterfactualInternal, calculatePlanningAsyncInternal, - analyzeCounterfactualInternal + calculatePlanningInternal, + explainIncompleteCheckpoint, + invalidInputFor }; diff --git a/src/app/services/frozen-engine/random-planning-requests.ts b/src/app/services/frozen-engine/random-planning-requests.ts new file mode 100644 index 0000000..d6e2489 --- /dev/null +++ b/src/app/services/frozen-engine/random-planning-requests.ts @@ -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 & { rules: Record[] }; + +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 = (items: readonly T[]): T => items[int(0, items.length - 1)]; + const chance = (p: number) => rnd() < p; + const shuffle = (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[] = []; + 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[] = shuffle( + createOptimizationPresetRules() as unknown as Record[], + ).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 = (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[]; + 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)], + }; +} diff --git a/src/app/services/leave-planner-explanation.spec.ts b/src/app/services/leave-planner-explanation.spec.ts index 881e642..2a35ef9 100644 --- a/src/app/services/leave-planner-explanation.spec.ts +++ b/src/app/services/leave-planner-explanation.spec.ts @@ -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' }, diff --git a/src/app/services/leave-planner-obligation-solutions.spec.ts b/src/app/services/leave-planner-obligation-solutions.spec.ts new file mode 100644 index 0000000..427da39 --- /dev/null +++ b/src/app/services/leave-planner-obligation-solutions.spec.ts @@ -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']], + ]); + }); +}); diff --git a/src/app/services/leave-planner-supporting-placements.spec.ts b/src/app/services/leave-planner-supporting-placements.spec.ts index 1e87b94..e32d05f 100644 --- a/src/app/services/leave-planner-supporting-placements.spec.ts +++ b/src/app/services/leave-planner-supporting-placements.spec.ts @@ -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, }), ]); diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index f91447d..a3ca016 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -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; 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 = Generator; -// 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(['cp', 'rtt', 'other']); const VALID_SELECTORS = new Set(['cp', 'rtt', 'other', 'any']); const VALID_MODES = new Set(['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(), ): PlanningSteps { 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 { + // Releasing a donor scores the same for every week of this plan. + const donorObjectives = new Map(); for (const week of context.holidayWeeks) - yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept); + yield* calendarPlacementCandidates( + state, + week.dates, + ruleId, + context, + accept, + donorObjectives, + ); } function* timeOffPeriodPlacementCandidates( @@ -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, ): Generator { 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((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((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 & { - 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({ diff --git a/src/app/services/plan-observations.ts b/src/app/services/plan-observations.ts index 6c54ccd..69b25fd 100644 --- a/src/app/services/plan-observations.ts +++ b/src/app/services/plan-observations.ts @@ -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); } diff --git a/src/app/services/planning-duration.service.spec.ts b/src/app/services/planning-duration.service.spec.ts index c4ed358..fd3b655 100644 --- a/src/app/services/planning-duration.service.spec.ts +++ b/src/app/services/planning-duration.service.spec.ts @@ -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', }); }); diff --git a/src/app/services/planning-duration.service.ts b/src/app/services/planning-duration.service.ts index e6192e2..f2b0767 100644 --- a/src/app/services/planning-duration.service.ts +++ b/src/app/services/planning-duration.service.ts @@ -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; } diff --git a/src/app/services/planning-objectives.ts b/src/app/services/planning-objectives.ts index 1efc89d..2398468 100644 --- a/src/app/services/planning-objectives.ts +++ b/src/app/services/planning-objectives.ts @@ -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[];