Compare commits

...
Author SHA1 Message Date
nessarandClaude Opus 5.5 3bbe989eca test(benchmark): print a readable summary table
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m5s
CI / build (push) Successful in 1m38s
CI / policy (pull_request) Successful in 10s
CI / security (push) Successful in 1m19s
CI / test (push) Successful in 3m29s
CI / validate (pull_request) Successful in 1m42s
CI / benchmark (push) Successful in 4m13s
CI / security (pull_request) Successful in 56s
CI / build (pull_request) Successful in 1m42s
CI / test (pull_request) Successful in 3m8s
CI / images (push) Successful in 1m15s
CI / required (push) Successful in 1s
CI / benchmark (pull_request) Successful in 3m37s
CI / images (pull_request) Successful in 1m11s
CI / required (pull_request) Successful in 1s
Print the calibration and a Markdown table with one aligned row per
scenario as soon as it finishes: both planning times, mean, budget, ratio,
multiplier, outcome, placements, determinism and budget status. Write
directly to stdout so Vitest adds no per-line headers. The complete TSV
report still follows the table.

Refs #281, #285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:09:54 +02:00
nessarandClaude Opus 5.5 cfdb28c90a ci(benchmark): size the hang guard for the slowest runner
CI / policy (push) Successful in 6s
CI / validate (push) Successful in 53s
CI / build (push) Successful in 1m18s
CI / policy (pull_request) Successful in 9s
CI / security (push) Successful in 1m12s
CI / validate (pull_request) Successful in 1m18s
CI / benchmark (push) Successful in 3m56s
CI / test (push) Successful in 4m6s
CI / build (pull_request) Successful in 36s
CI / security (pull_request) Successful in 1m5s
CI / test (pull_request) Successful in 3m2s
CI / images (push) Successful in 1m15s
CI / required (push) Successful in 1s
CI / benchmark (pull_request) Successful in 7m18s
CI / images (pull_request) Successful in 1m50s
CI / required (pull_request) Successful in 1s
The first PR run landed on srvhome, about 1.9 times slower than
nessar-desktop. The fixed 300-second timeout killed its protocol during the
loaded year, although every completed scenario was within its relative
budget. Raise the CI timeout to 600 seconds and the test timeout to 570
seconds so they only guard against hangs, and record the first CI-runner
ratios.

Refs #285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:40:41 +02:00
nessarandClaude Opus 5.5 63b50c4311 docs(benchmark): record the remaining gap to the speed target
CI / policy (push) Successful in 6s
CI / validate (push) Successful in 1m1s
CI / build (push) Successful in 1m27s
CI / policy (pull_request) Successful in 9s
CI / security (push) Successful in 1m16s
CI / validate (pull_request) Successful in 1m34s
CI / benchmark (push) Successful in 4m31s
CI / test (push) Successful in 4m36s
CI / build (pull_request) Successful in 39s
CI / test (pull_request) Successful in 3m24s
CI / security (pull_request) Successful in 1m11s
CI / images (push) Successful in 1m16s
CI / required (push) Successful in 1s
CI / benchmark (pull_request) Failing after 6m0s
CI / images (pull_request) Skipped
CI / required (pull_request) Failing after 2s
The exact optimizations bring the loaded year from 66.0 s to 44.1 s, but the
target of 10 s for the loaded year and 3 s for the other scenarios is not
reached for the loaded year, typed obligations and three years. Record the
gap and the remaining profile, keep the relative-gate multipliers and the
300-second hang guard unchanged, and note that ADR 0003's five-second target
should be revisited together with a decision on search changes.

Refs #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:45:28 +02:00
nessarandClaude Opus 5.5 acdb19352a ci(benchmark): gate scenario speed relative to the runner
Before the protocol, time the frozen engine on the two-year scenario, best
of three, and fail any scenario whose mean planning time exceeds its
multiplier times that calibration. Multipliers come from development-machine
ratios with about 1.5x headroom until #286 tightens them. The 300-second
timeout stays only as a hang guard. The benchmark keeps running on push and
pull_request events because the push run gates image publication.

This replaces the absolute 300-second acceptance protocol named in #78.

Refs #285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:45:28 +02:00
nessarandClaude Opus 5.5 68a9e33de6 perf(planning): prune candidates that cannot beat the protected prefix
Finalizing a candidate never moves or retypes a placement, so preference
results and calendar optimizations already have their final values before
it, and leave-balance optimizations do too when nothing can be earned.
Reject a candidate before finalizing when the first differing such
objective of the protected prefix is worse than the baseline. Leave-placement
objectives and earning contexts stop the walk.

The frozen-engine check now also compares cancelled asynchronous runs,
including checkpoints and retained incumbents. The loaded year drops from
48.5 s to 44.3 s; outcomes, explanations and final states are unchanged.

Refs #284

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:45:28 +02:00
nessarandClaude Opus 5.5 7890a03a74 perf(planning): update plan observations incrementally
Cache an immutable occupancy per placements map and derive a candidate's
time-off period groups, holiday-week completeness and placement tie key
from its parent when one date is added or removed. The from-scratch
computation stays as the test oracle and an opt-in shadow mode. A
fast-check property test compares both after every operation.

The loaded year drops from 66.5 s to 48.5 s and the protocol from 262.0 s
to 193.7 s. Outcomes, explanations and final states stay identical.

Refs #283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:45:28 +02:00
nessarandClaude Opus 5.5 87d72064be refactor(planning): consolidate plan observations in one module
Move time-off periods, work sequences, public-holiday weeks, balance
observations, the placement list and both tie keys into
plan-observations.ts. The code moves without behavior changes; all seven
benchmark scenarios produce byte-identical outcomes, explanations and final
states, and the frozen-engine check passes. Benchmark timings stay within
noise.

Refs #282

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:47:34 +02:00
nessarandClaude Opus 5.5 24c35542a8 test(benchmark): report each scenario as it finishes
Print the report header before the measured runs and each scenario's rows
as soon as its repetitions complete, so a timed-out job still shows the
finished scenarios. The final report, scenarios, warm-ups, repetitions,
assertions, heap limit and timeout are unchanged.

Refs #281

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:36:11 +02:00
nessarandClaude Opus 5.5 4d26e65672 test(planning): compare the engine against a frozen engine on random requests
Bundle the planning core at a871f8f (develop after PR #278) as a frozen
fixture and compare it with the current engine on 5,000 seeded random
requests. Outcomes, stable explanations, explanation errors and final-state
snapshots must be strictly deep-equal. Requests cover obligations,
preferences, IF_THEN and TIME_WINDOW rules, earned entitlements, composed
optimization rules, declared days, year boundaries and public holidays.

Refs #280

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:30:30 +02:00
nessar a871f8f28c add a skill
CI / policy (push) Failing after 9s
CI / validate (push) Successful in 1m3s
CI / build (push) Successful in 1m21s
CI / security (push) Successful in 57s
CI / test (push) Successful in 2m22s
CI / benchmark (push) Successful in 5m10s
CI / images (push) Skipped
CI / required (push) Failing after 1s
2026-10-16 22:47:08 +02:00
nessar a371e03462 perf(planning): restore the integrated benchmark gate
CI / policy (push) Successful in 18s
CI / validate (push) Successful in 1m17s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 1m4s
CI / test (push) Successful in 2m30s
CI / benchmark (push) Failing after 5m55s
CI / images (push) Skipped
CI / required (push) Failing after 2s
See pull request #278
2026-10-16 22:36:31 +02:00
20 changed files with 5150 additions and 318 deletions

No files matched your search

@@ -0,0 +1,36 @@
---
name: tiered-verified-implement
description: Implement a spec or ticket in Claude Code by delegating the work to the cheapest Claude model that can handle each piece (Haiku for mechanical edits, Sonnet for normal work), then independently verify the final change on the orchestrator's model. Use when the user asks to implement, build, or fix work and wants it done cheaply, on a cheaper model, or with delegated implementation and a verification pass.
---
# Tiered Verified Implement
Invoke `/implement` and follow it as the baseline workflow. This skill is its cost-saving, delegated, verified variant: cheap models write the code, the orchestrator's model checks it.
## Model tiers
Pick the cheapest tier that can do each piece without guessing:
- `haiku`: mechanical work with no design decisions — renames, boilerplate, fixture or snapshot updates, wiring an existing pattern into one more place, translations, doc edits.
- `sonnet`: the default — new logic, bug fixes, tests for real behavior, anything touching several files in one seam.
- Orchestrator (yourself): only for work a cheap agent failed on twice, or that needs a consequential design call the spec leaves open. Ask the user about such calls before delegating rather than letting a cheap agent guess.
When unsure between two tiers, choose the lower one; escalation is cheaper than defaulting high.
## Workflow
1. Read the spec or ticket, repository guidance, affected flow, and existing worktree changes. Record the starting commit and task-owned scope; preserve unrelated changes.
2. Split only genuinely independent, bounded work. If the change is small or one seam owns it, use one implementation agent; do not manufacture parallelism. Assign each piece a tier.
3. Spawn each implementation agent with the Agent tool: `subagent_type: "general-purpose"` and `model` set to its tier. Never use `subagent_type: "fork"` for this — forks ignore the `model` override and run on the expensive orchestrator model. Fresh agents start cold, so the brief carries everything: exact owned files/seam, acceptance criteria, relevant file paths and existing patterns to copy, required checks (targeted tests, typecheck), and an instruction not to overwrite or revert other agents' work. A precise brief is the main cost lever: it stops the cheap agent from re-exploring the repo.
4. Wait for agents, inspect their diffs (not their whole exploration), resolve overlaps minimally, and run the checks required by `/implement`: targeted tests and typecheck during work, then the full relevant suite once.
5. If an agent's result fails checks or misses the spec, send it back once with the concrete failure (SendMessage keeps its context). If it fails again, escalate one tier: haiku → sonnet → orchestrator.
6. Spawn a fresh, read-only verification agent (`subagent_type: "general-purpose"`) with `model` set explicitly to the orchestrator's own tier (e.g. `opus` when you run on Opus). Set it explicitly rather than omitting it, since a configured default subagent model could silently downgrade the verifier. Give it the original request, starting commit, current diff, and test output, but not implementation conclusions. Ask it to check correctness, regressions, specification coverage, and test evidence, and to report findings without changing files.
7. Fix confirmed findings (delegate back to the cheap tier when the fix is mechanical), rerun affected checks, and repeat verification once if fixes were needed. Run `/code-review` on the current working result (including staged, unstaged, and new files), passing the original spec, starting commit, and task-owned scope. Follow `/implement`'s commit boundary: commit only when requested, otherwise hand off the verified diff.
In the final report, list which tier did each piece and any escalations, so the user can see where the cheap models held up.
## Delegation rules
- Keep all agents in the same workspace; assign non-overlapping ownership before they edit.
- Do not delegate secrets, production writes, destructive operations, or changes requiring new user authority.
- If subagents are unavailable, implement directly, run the same checks, and state that delegated implementation and verification were unavailable.
+1 -1
View File
@@ -33,4 +33,4 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
- **Unit Tests and Coverage**: generate the language registry, then run the coverage command in rule 1, matching the Gitea functional-test job.
- **CI policy and publication**: `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs`
- **Production Build**: `bun run build -- --configuration production`
- The separate performance benchmark remains advisory while issue #78 is open. Run it outside coverage with `NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1` when verifying performance; see `docs/benchmark/README.md`.
- The separate performance benchmark is a required CI job with a relative gate: each scenario's budget is a multiple of the frozen engine's two-year calibration measured in the same run. Run it outside coverage with `NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/benchmark/ --maxWorkers=1` when verifying performance; see `docs/benchmark/README.md`.
Symlink
+1
View File
@@ -0,0 +1 @@
.agents/
+2 -2
View File
@@ -85,8 +85,8 @@ jobs:
- run: npm install --global bun@1.4.2
- run: bun install --frozen-lockfile
- run: bun scripts/generate-langs.js
- name: Unchanged benchmark protocol
run: timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
- name: Benchmark protocol with relative gate
run: timeout 600s bunx vitest run src/app/benchmark/ --maxWorkers=1
build:
name: build
+2
View File
@@ -6,6 +6,8 @@
/out-tsc
/bazel-out
.scratch/
# Node
/node_modules
npm-debug.log
+6 -1
View File
@@ -42,6 +42,7 @@
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"fast-check": "4.10.2",
"jest": "^30.4.2",
"jest-preset-angular": "^17.0.0",
"jsdom": "^30.0.0",
@@ -1405,6 +1406,8 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
@@ -1969,7 +1972,7 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
@@ -2949,6 +2952,8 @@
"jest-circus/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="],
"jest-circus/pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
"jest-cli/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="],
"jest-cli/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
+102 -1
View File
@@ -6,10 +6,68 @@
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 retains its 300-second timeout. All expected outcomes,
The full protocol keeps a 600-second timeout only as a 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.
## Relative CI gate
The relative gate replaces the 300-second acceptance protocol named in
[#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) as the benchmark
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.
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.
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
mean, the budget, the ratio to the calibration, the multiplier, the outcome,
placements, determinism and `ok`, `OVER` or `no budget`. The complete
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 |
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.
The first CI runs of PR #287 (commit `63b50c4`) give consistent ratios on two
runners:
| Scenario | nessar-desktop | srvhome |
| --------------------------- | -------------- | ------- |
| calibration | 3.03 s | 5.71 s |
| representative year | 0.17 | 0.14 |
| typed obligations | 1.83 | 1.75 |
| chained earned entitlements | 0.011 | 0.005 |
| deliberate conflict | 0.006 | 0.003 |
| loaded year | 16.6 | killed |
| two years | 0.65 | killed |
| three years | 1.99 | killed |
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.
The post-removal functional integration checks, browser QA and unchanged-input
handoff for #78 are recorded in [the #77 verification report](../trace-free-planning-verification.md).
@@ -44,6 +102,49 @@ The measurements below describe earlier implementations and protocols. Their
trace counters and timings are historical; current evidence is in
[the #78 report](issue-78.md).
## Frozen-engine differential check
`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,
explanation error and final-state snapshot must be strictly deep-equal,
including `PlanningConflict` and `InvalidInput` outcomes. Timings are not
compared.
Start dates span 2025 to 2027. About 15% of requests start in late December and
cross a year boundary, and about 15% start shortly before a weekday French
public holiday. Most horizons last 7 to 28 days; about 10% last 29 to 70 days
with optimization presets and at most one calendar rule. Requests cover
obligation and preference rules, `IF_THEN` rules with earned leave
entitlements, `TIME_WINDOW` rules, optimization presets in random order and
subsets, random composed optimization configurations, disabled rules, declared
non-working days and a few deliberately invalid requests. The last test fails
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 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:
```sh
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
```
A failure prints the seed and the full request as JSON. To run only that seed:
```sh
FROZEN_ENGINE_SEED=<seed> bunx vitest run src/app/services/frozen-engine.spec.ts
```
The check runs in the functional suite. On the development laptop it takes
about 32 seconds without coverage and 38 seconds with coverage
(`NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/services/frozen-engine.spec.ts --maxWorkers=1`).
A flipped objective direction in `compareObjectiveValues` made 26 of its tests
fail, starting at seed 280002; the change was reverted.
## Reference-phone protocol
Open the deployed application at `/benchmark` on the reference phone. Tap
+200
View File
@@ -0,0 +1,200 @@
# Issue #279: incremental exact scoring
Step-by-step record for [issue #279](https://gitea.nessar.fr/nessar/vac-optimizer/issues/279).
Each step keeps planning outcomes, stable explanations and final states identical
to develop after PR #278 (`a871f8f`). The
[frozen-engine differential check](README.md#frozen-engine-differential-check)
guards every step.
All measurements use Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the development
laptop, with no other test, build or profiling workload running alongside the
protocol. Only same-session before/after pairs are comparable.
```sh
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
```
The per-scenario figures below are the means of the two measured calculations.
## Baseline profile
A CPU profile of the bundled core over all seven scenarios, with
`calculationDate: '2024-01-01'`, put 50.5 of 67.0 loaded-year seconds in
candidate evaluation (`evaluateRuleCandidate`). Of those, 41.3 seconds went to
active-objective evaluation: 15.6 seconds in time-off-period observations,
11.5 seconds in building observations and 7.6 seconds in sorting placement dates
for the placement tie key and the observation cache key. The asynchronous
adapter's 1 ms timer yields cost about 7% on typed obligations.
## #282: consolidate plan observations
Time-off periods, work sequences, public-holiday weeks, balance observations,
the placement list and both tie keys now live in
`src/app/services/plan-observations.ts`. The code moved without behavior
changes. All seven scenarios produce byte-identical outcomes, explanations and
final states compared with `a871f8f`.
| Measurement | Before | After |
| ------------------- | --------: | --------: |
| Vitest duration | 262.000 s | 262.030 s |
| Loaded year | 66.010 s | 66.497 s |
| Typed obligations | 4.887 s | 4.964 s |
| Two years | 2.914 s | 2.977 s |
| Three years | 10.203 s | 10.049 s |
| Representative year | 0.557 s | 0.569 s |
The differences are within run-to-run noise. The functional suite passes
666 tests across 34 files, including the frozen-engine check.
## #283: incremental plan observations
Each placements map now carries an immutable occupancy: its sorted placement
dates, time-off period groups, public-holiday-week completeness and lazily
computed calendar observations. A candidate derived from its parent by one added
or removed date, or by a copy with the same dates, updates only the affected
groups and holiday weeks. Unchanged groups keep their cached periods and
observation objects. Maps built from snapshots, maps observed against another
calendar and dates that are not in-horizon working days fall back to the
from-scratch computation.
The from-scratch computation remains the oracle. A fast-check property test
applies random add, remove and retype sequences across year boundaries, horizon
edges, declared days, weekends and public holidays, and compares every step with
it. An opt-in shadow mode (`VAC_OBSERVATION_SHADOW=1` or
`setObservationShadowMode(true)`) compares both on every observation and throws
on any mismatch. It passed across the frozen-engine check and all seven
benchmark scenarios, whose outcomes, explanations and final states stay
byte-identical.
| Measurement | Before | After |
| ------------------- | --------: | --------: |
| Vitest duration | 262.030 s | 193.680 s |
| Loaded year | 66.497 s | 48.489 s |
| Typed obligations | 4.964 s | 4.947 s |
| Two years | 2.977 s | 1.906 s |
| Three years | 10.049 s | 6.152 s |
| Representative year | 0.569 s | 0.441 s |
A fresh loaded-year CPU profile of the bundled core took 46.8 seconds. Of the
1.68 million observed placement maps, one was built from scratch; the rest were
derived. Observations now take about 4 seconds. Candidate evaluation still
dominates: 24.3 seconds in `evaluateRuleCandidate`, including 15.2 seconds of
active objectives and 6.9 seconds of candidate finalization, plus 6.2 seconds
assigning dates and about 5 seconds each of garbage collection and timer idle
time.
The functional suite passes 669 tests across 35 files.
## #284: prune candidates that cannot beat the protected prefix
Before #284, `evaluateRuleCandidate` finalized every candidate before comparing
its objectives. In the loaded year, 650,369 of 1,233,535 evaluations (53%) then
ended at a worse protected prefix. For typed obligations it was 74,336 of
273,470, and for three years 38,048 of 193,621.
Finalizing never moves or retypes a placement. It edits only rule
provenance, supporting status, earned activations and earned balances. Before
finalizing, the evaluation therefore walks the candidate's protected prefix in
objective order, using only objectives whose pre-finalize value is already their
exact final value, so the bound is tight:
- time-window and if-then preferences read only placement dates and types,
plus declared days;
- public-holiday-week, time-off-period and work-sequence optimizations read
only the placement dates;
- leave-balance optimizations are exact only when no enabled if-then rule earns
days and the candidate has no earned activation;
- leave-placement optimizations have no safe bound, because finalizing rewrites
supporting status.
The walk prunes at the first differing objective when it is worse than the
baseline. It stops without pruning at a better value or at an objective without
a safe bound. A pruned candidate would have failed finalization or returned at
the same comparison, and incumbents are retained only after the full objective
vector is built. Pruning therefore never changes which candidate wins or which
incumbent is retained.
The frozen-engine check now also runs every tenth seed through both engines'
asynchronous adapter, cancelled after a seed-derived number of steps. It
compares outcomes, artifacts, every checkpoint, the explanation and outcome
callbacks, and the step count. Of those 500 cases, 422 end incomplete and 217
of those keep an incumbent. Pruning on any differing objective breaks both
variants. Wrongly treating leave-placement or earning leave-balance objectives
as exact went undetected, because the generated requests rarely put those
objectives in a protected prefix, so their exclusion rests on the argument
above. The check now takes about 60 seconds with coverage.
| Measurement | Before | After |
| ------------------- | --------: | --------: |
| Vitest duration | 193.680 s | 180.250 s |
| Loaded year | 48.489 s | 44.287 s |
| Typed obligations | 4.947 s | 5.023 s |
| Two years | 1.906 s | 1.837 s |
| Three years | 6.152 s | 5.935 s |
| Representative year | 0.441 s | 0.400 s |
All seven scenarios keep byte-identical outcomes, explanations and final states.
The functional suite passes 684 tests across 35 files.
## #286: speed target not reached
The target is a loaded year under 10 seconds and every other scenario under 3
seconds, using the existing protocol in Node on the development laptop. The
exact optimizations of #282 to #284 do not reach it. Means from the latest
protocol run, with the #285 calibration of 2.854 seconds:
| Scenario | Target | Latest | Gap factor |
| --------------------------- | -----: | -------: | ---------: |
| Loaded year | < 10 s | 44.094 s | 4.4x |
| Typed obligations | < 3 s | 5.071 s | 1.7x |
| Three years | < 3 s | 5.887 s | 2.0x |
| Two years | < 3 s | 1.865 s | met |
| Representative year | < 3 s | 0.402 s | met |
| Chained earned entitlements | < 3 s | 0.025 s | met |
| Deliberate conflict | < 3 s | 0.013 s | met |
From develop after PR #278 to now, the loaded year went from 66.0 to 44.1
seconds (−33%), three years from 10.2 to 5.9 seconds and two years from 2.9 to
1.9 seconds. Typed obligations is unchanged at about 5 seconds. The protocol went
from 262.0 to 180.3 seconds.
Because the target is not met, the #285 multipliers stay at current speed and
the hang-guard timeout is not lowered. #286 remains open for a
separate decision on search changes.
### Remaining loaded-year profile
A fresh CPU profile of the bundled core at the #285 commit took 43.5 seconds,
with outcomes still byte-identical:
| Inclusive cost | Seconds |
| ----------------------------------------------------------------------- | ------: |
| Candidate evaluation (`evaluateRuleCandidate`) | 21.4 |
| — active objectives, including the pruning walk | 16.7 |
| — of which objective evaluation (`evaluateOptimization`) | 7.7 |
| — of which plan observations | 4.2 |
| — candidate finalization | 2.7 |
| Date assignment and release (`assignDateToBalance`, `releasePlacement`) | 8.1 |
| Garbage collection | 5.1 |
| Idle time between asynchronous slices | 4.6 |
The remaining cost is spread over about 1.2 million candidate evaluations
rather than concentrated in one computation. Further exact work could:
- yield with something cheaper than a 1 ms timer between 8 ms slices, saving up
to the 4.6 seconds of idle time;
- validate optimization configurations once instead of on every evaluation,
about 1 second;
- copy fewer balances and earned activations when assigning a date.
Together these are worth perhaps 8 to 10 seconds, still well above 10 seconds
for the loaded year. Reaching the target needs fewer candidate evaluations,
which is a search change outside #279's exactness constraint.
### ADR 0003
ADR 0003 targets five seconds for the dependency-free planner. Under composable
optimization rules, the loaded year takes about 44 seconds in Node on the
development laptop even after these exact optimizations. That target should be
revisited for the loaded year, together with the decision on search changes.
This record does not revise the ADR.
+18 -10
View File
@@ -16,16 +16,24 @@ synchronization, and title/body edits. Manual dispatch runs validation without
publishing images. There is no schedule or deployment job in the preserved
configuration; the Compose files remain manual deployment options.
| Job | Behavior |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | Tests the policy and publisher, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `validate` | Runs lint, formatting, and application/test type checks. |
| `test` | Runs functional coverage with one worker and a 2 GiB Node heap; uploads coverage for seven days even on failure. |
| `benchmark` | Required seven-scenario protocol, with one warm-up and two measured repetitions, outside coverage with one worker, a 2 GiB Node heap and the unchanged 300-second timeout. |
| `build` | Builds browser/server production bundles 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. |
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. |
| Job | Behavior |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | Tests the policy and publisher, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `validate` | Runs lint, formatting, and application/test type checks. |
| `test` | Runs functional coverage with one worker and a 2 GiB Node heap; uploads coverage for seven days even on failure. |
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage with one worker and a 2 GiB Node heap. 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. |
| `build` | Builds browser/server production bundles 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. |
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. |
The benchmark runs on both push and pull_request events: the push run gates image
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
those runs.
Push and PR runs are separate so PR title and rebase checks run on the PR event.
New runs cancel older runs for the same event/ref. Image jobs serialize per
+1
View File
@@ -64,6 +64,7 @@
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"fast-check": "4.10.2",
"jest": "^30.4.2",
"jest-preset-angular": "^17.0.0",
"jsdom": "^30.0.0",
@@ -0,0 +1,63 @@
import {
BenchmarkMeasurement,
benchmarkSummaryHeader,
benchmarkSummaryRow,
budgetViolations,
} from './leave-planner-benchmark';
const measure = (scenario: string, planningMs: number) =>
({ scenario, planningMs }) as BenchmarkMeasurement;
describe('benchmark budget gate', () => {
const multipliers = { a: 2 };
it('accepts a mean within multiplier x calibration', () => {
expect(budgetViolations([measure('a', 150), measure('a', 250)], 100, multipliers)).toEqual(
[],
);
});
it('reports measured time, budget, multiplier and calibration when over budget', () => {
const [message] = budgetViolations(
[measure('a', 300), measure('a', 320)],
100,
multipliers,
);
expect(message).toContain('a:');
expect(message).toContain('measured 310.0 ms');
expect(message).toContain('budget 200.0 ms');
expect(message).toContain('multiplier 2');
expect(message).toContain('calibration 100.0 ms');
});
it('fails a scenario without a multiplier', () => {
expect(budgetViolations([measure('new', 1)], 100, multipliers)).toEqual([
'new: no budget multiplier defined',
]);
});
});
describe('benchmark summary table', () => {
const runs = (planningMs: number[]) =>
planningMs.map(
(ms, index) =>
({
scenario: 'a',
planningMs: ms,
outcome: 'Plan',
lastCompletedStage: 'dates',
placements: 3,
deterministic: index ? 'yes' : 'baseline',
}) as BenchmarkMeasurement,
);
it('aligns rows with the header', () => {
const [header, separator] = benchmarkSummaryHeader().split('\n');
const row = benchmarkSummaryRow(runs([150, 250]), 100, { a: 2 });
expect(row).toHaveLength(header.length);
expect(separator).toHaveLength(header.length);
expect(row).toContain('0.15 / 0.25 s');
expect(row).toContain('Plan (dates)');
expect(row).toMatch(/\| ok +\|$/);
});
it('marks scenarios over budget or without a budget', () => {
expect(benchmarkSummaryRow(runs([300, 320]), 100, { a: 2 })).toMatch(/\| OVER +\|$/);
expect(benchmarkSummaryRow(runs([1]), 100, {})).toMatch(/\| no budget \|$/);
});
});
@@ -1,8 +1,13 @@
import { vi } from 'vitest';
import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-planner.core.frozen.mjs';
import { createPlanningCalculation } from '../services/leave-planner.service';
import {
BENCHMARK_BUDGET_MULTIPLIERS,
benchmarkReport,
benchmarkScenarios,
benchmarkSummaryHeader,
benchmarkSummaryRow,
budgetViolations,
runLeavePlannerBenchmark,
} from './leave-planner-benchmark';
@@ -64,7 +69,25 @@ describe('leave planner benchmark protocol', () => {
),
).toHaveLength(2);
}
const measurements = await runLeavePlannerBenchmark(2);
// Frozen engine (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++) {
const request = structuredClone(calibrationRequest);
const start = performance.now();
await calculatePlanningAsyncInternal({ ...request, calculationDate: '2024-01-01' });
calibrationMs = Math.min(calibrationMs, performance.now() - start);
}
// Direct writes skip Vitest's per-call "stdout |" headers and appear immediately.
const print = (text: string) => process.stdout.write(`${text}\n`);
print(
`\nCalibration: frozen engine on "two years", best of 3 = ${calibrationMs.toFixed(1)} ms` +
'\nBudget = limit x calibration; ratio = mean / calibration.\n',
);
print(benchmarkSummaryHeader());
const measurements = await runLeavePlannerBenchmark(2, (rows) =>
print(benchmarkSummaryRow(rows, calibrationMs)),
);
for (const [name, placements] of [
['two years', 60],
['three years', 90],
@@ -96,13 +119,20 @@ describe('leave planner benchmark protocol', () => {
loaded[0].preparationMs + loaded[0].planningMs,
);
const report = benchmarkReport(measurements);
console.info(report);
print(`\nRaw report (TSV):\n${report}\n`);
expect(report.split('\n')).toHaveLength(15);
expect(report).toContain('planning_ms\twall_ms');
expect(report).toContain('planning_work_ms\tpeak_main_thread_heap_bytes');
expect(report).toContain('\tbaseline\t');
const violations = budgetViolations(
measurements,
calibrationMs,
BENCHMARK_BUDGET_MULTIPLIERS,
);
if (violations.length)
throw new Error(`Benchmark over budget:\n${violations.join('\n')}`);
} finally {
vi.stubGlobal('window', browserWindow);
}
}, 300_000);
}, 570_000);
});
+107 -2
View File
@@ -193,18 +193,24 @@ function resultSummary(outcome: PlanningOutcome): {
return { outcome: outcome.kind, placements: 0 };
}
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
export async function runLeavePlannerBenchmark(
repetitions = 1,
onScenario?: (measurements: BenchmarkMeasurement[]) => void,
): Promise<BenchmarkMeasurement[]> {
const scenarios = benchmarkScenarios();
for (const scenario of scenarios) await warmUpScenario(scenario);
const measurements: BenchmarkMeasurement[] = [];
for (const scenario of scenarios) {
const scenarioMeasurements: BenchmarkMeasurement[] = [];
let baseline: string | undefined;
for (let repetition = 1; repetition <= repetitions; repetition++) {
const result = await measureRepetition(scenario, repetition, baseline);
measurements.push(result.measurement);
scenarioMeasurements.push(result.measurement);
baseline ??= result.stable;
}
measurements.push(...scenarioMeasurements);
onScenario?.(scenarioMeasurements);
}
return measurements;
}
@@ -294,3 +300,102 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
),
].join('\n');
}
// Measured on the development machine (ratio to the frozen two-years run), tightened by #286.
export const BENCHMARK_BUDGET_MULTIPLIERS: Record<string, number> = {
'representative year': 0.25,
'typed obligations': 3,
'chained earned entitlements': 0.05,
'deliberate conflict': 0.05,
'loaded year': 25,
'two years': 1,
'three years': 3.5,
};
/** One message per scenario whose mean planning time exceeds multiplier x calibration, or that has no multiplier. */
export function budgetViolations(
measurements: BenchmarkMeasurement[],
calibrationMs: number,
multipliers: Record<string, number> = BENCHMARK_BUDGET_MULTIPLIERS,
): string[] {
const byScenario = new Map<string, number[]>();
for (const { scenario, planningMs } of measurements)
byScenario.set(scenario, [...(byScenario.get(scenario) ?? []), planningMs]);
const violations: string[] = [];
for (const [scenario, times] of byScenario) {
const mean = average(times);
const multiplier = multipliers[scenario];
if (multiplier === undefined) {
violations.push(`${scenario}: no budget multiplier defined`);
continue;
}
const budget = multiplier * calibrationMs;
if (mean > budget)
violations.push(
`${scenario}: measured ${mean.toFixed(1)} ms > budget ${budget.toFixed(1)} ms (multiplier ${multiplier} x calibration ${calibrationMs.toFixed(1)} ms)`,
);
}
return violations;
}
function average(values: number[]): number {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
// Fixed widths keep rows printed as each scenario finishes aligned with the header.
const SUMMARY_COLUMNS: [title: string, width: number, alignRight: boolean][] = [
['Scenario', 27, false],
['Planning (runs)', 17, true],
['Mean', 9, true],
['Budget', 9, true],
['Ratio', 6, true],
['Limit', 6, true],
['Outcome', 16, false],
['Placements', 10, true],
['Deterministic', 13, false],
['Status', 9, false],
];
function summaryLine(cells: string[]): string {
return `| ${cells
.map((cell, index) => {
const [, width, alignRight] = SUMMARY_COLUMNS[index];
return alignRight ? cell.padStart(width) : cell.padEnd(width);
})
.join(' | ')} |`;
}
/** Markdown table header for {@link benchmarkSummaryRow}. */
export function benchmarkSummaryHeader(): string {
return [
summaryLine(SUMMARY_COLUMNS.map(([title]) => title)),
`|${SUMMARY_COLUMNS.map(([, width, alignRight]) => (alignRight ? `${'-'.repeat(width + 1)}:` : `:${'-'.repeat(width + 1)}`)).join('|')}|`,
].join('\n');
}
/** One aligned table row for one scenario's measured repetitions, judged like {@link budgetViolations}. */
export function benchmarkSummaryRow(
scenarioMeasurements: BenchmarkMeasurement[],
calibrationMs: number,
multipliers: Record<string, number> = BENCHMARK_BUDGET_MULTIPLIERS,
): string {
const [first] = scenarioMeasurements;
const seconds = (ms: number) => (ms / 1000).toFixed(2);
const mean = average(scenarioMeasurements.map(({ planningMs }) => planningMs));
const multiplier = multipliers[first.scenario];
const budget = multiplier === undefined ? undefined : multiplier * calibrationMs;
return summaryLine([
first.scenario,
`${scenarioMeasurements.map(({ planningMs }) => seconds(planningMs)).join(' / ')} s`,
`${seconds(mean)} s`,
budget === undefined ? '-' : `${seconds(budget)} s`,
(mean / calibrationMs).toFixed(2),
multiplier === undefined ? '-' : String(multiplier),
first.lastCompletedStage ? `${first.outcome} (${first.lastCompletedStage})` : first.outcome,
String(first.placements),
scenarioMeasurements.slice(1).every(({ deterministic }) => deterministic === 'yes')
? 'yes'
: 'no',
budget === undefined ? 'no budget' : mean > budget ? 'OVER' : 'ok',
]);
}
+328
View File
@@ -0,0 +1,328 @@
import { afterAll, describe, expect, it } from 'vitest';
import { createOptimizationPresetRules } from './rule-service';
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
import { OPTIMIZATION_CATALOG } from './planning-objectives';
import {
calculatePlanningAsyncInternal,
calculatePlanningInternal,
type PlanningArtifacts,
type PlanningOutcome,
} from './leave-planner.core';
import {
calculatePlanningAsyncInternal as frozenCalculateAsync,
calculatePlanningInternal as frozenCalculate,
} from './frozen-engine/leave-planner.core.frozen.mjs';
/** Rebuild instructions for the frozen engine: docs/benchmark/README.md. */
const BASE_SEED = 280_000;
const CASES = 5000;
const CHUNK = 200;
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const DAY = 86_400_000;
const weekday = (ms: number) => ![0, 6].includes(new Date(ms).getUTCDay());
const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
type Req = Record<string, unknown> & { rules: Record<string, unknown>[] };
function generateRequest(seed: number): Req {
const rnd = mulberry32(seed);
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
const chance = (p: number) => rnd() < p;
const shuffle = <T>(items: T[]): T[] => {
for (let i = items.length - 1; i > 0; i--) {
const j = int(0, i);
[items[i], items[j]] = [items[j], items[i]];
}
return items;
};
const long = chance(0.1);
const mode = rnd();
let startMs = Date.UTC(2025, 0, 1) + int(0, 1094) * DAY;
if (mode < 0.15) startMs = Date.UTC(pick([2025, 2026]), 11, int(8, 31));
else if (mode < 0.3) {
const year = pick([2025, 2026, 2027]);
const holidays = [...frenchPublicHolidaysForYear(year)].filter((d) =>
weekday(Date.parse(d)),
);
startMs = Date.parse(pick(holidays)) - int(1, 10) * DAY;
}
const days = long ? int(29, 70) : int(7, 28);
const start = iso(startMs);
const date = (offset: number) => iso(startMs + offset * DAY);
const types = ['cp', 'rtt', 'other'] as const;
const balances = Array.from({ length: int(1, 3) }, (_, i) => ({
id: `b${i}`,
type: pick(types),
quantity: int(0, long ? 4 : 3),
...(chance(0.3) ? { availableFrom: date(int(1, days)) } : {}),
expiryDate: date(int(-3, days + 40)),
})).map((b) =>
b.availableFrom && b.availableFrom > b.expiryDate
? { ...b, expiryDate: b.availableFrom }
: b,
);
const declaredDays: { date: string; type: string }[] = [];
for (let n = int(0, 4); n > 0; n--) {
const ms = startMs + int(0, days) * DAY;
const d = iso(ms);
if (isWorkingPlanningDate(d) && !declaredDays.some((x) => x.date === d))
declaredDays.push({ date: d, type: pick(types) });
}
const selectors = ['cp', 'rtt', 'other', 'any'] as const;
const rules: Record<string, unknown>[] = [];
for (let n = long ? int(0, 1) : int(0, 2); n > 0; n--) {
const from = int(0, days - 1);
rules.push({
id: `tw${rules.length}`,
type: chance(0.5) ? 'time_window' : 'TIME_WINDOW',
force: pick(['obligation', 'preference']),
startDate: date(from),
endDate: date(from + int(0, 10)),
placeDays: int(1, 4),
placeType: pick(selectors),
});
}
for (let n = long && rules.length ? 0 : long ? int(0, 1) : int(0, 2); n > 0; n--) {
rules.push({
id: `it${rules.length}`,
type: chance(0.5) ? 'if_then' : 'IF_THEN',
force: pick(['obligation', 'preference']),
dayOfWeek: int(1, 5),
placeDays: int(1, 3),
placeType: pick(selectors),
placement: pick(['before', 'after', 'around', 'auto']),
rewardDays: int(0, 2),
rewardType: pick(types),
});
}
const presets: Record<string, unknown>[] = shuffle(
createOptimizationPresetRules() as unknown as Record<string, unknown>[],
).slice(0, long ? int(1, 4) : int(0, 3));
rules.push(...presets);
if (!long && chance(0.3)) {
const row = pick(OPTIMIZATION_CATALOG);
const conditions = (row.conditions as readonly string[]).filter(() => chance(0.5));
rules.push({
id: 'composed',
type: 'optimization',
force: 'preference',
configuration: {
elements: row.elements,
conditions,
measure: row.measure,
aggregation: pick(row.aggregations),
direction: pick(['minimize', 'maximize']),
...(row.measure === 'work-sequence-deficit' ? { thresholdDays: int(1, 8) } : {}),
},
});
}
for (const rule of shuffle(rules)) if (chance(0.08)) rule['enabled'] = false;
const request: Req = {
calculationDate: start,
horizon: { start, end: date(days) },
balances,
declaredDays,
rules,
};
if (chance(0.04)) {
const breakIt = int(0, 3);
if (breakIt === 0) balances[0].quantity = -1;
else if (breakIt === 1) request['horizon'] = { start: date(days), end: start };
else if (breakIt === 2) rules.push({ id: 'bad', type: 'unknown_type' });
else declaredDays.push({ date: start, type: 'nope' });
}
return request;
}
interface Run {
outcome: PlanningOutcome;
artifacts?: Pick<PlanningArtifacts, 'explanation' | 'finalState' | 'explanationError'>;
}
function run(
calculate: (
request: unknown,
c?: undefined,
a?: (artifacts: PlanningArtifacts) => void,
) => PlanningOutcome,
request: Req,
): Run {
let artifacts: Run['artifacts'];
const outcome = calculate(structuredClone(request), undefined, (a) => {
artifacts = {
explanation: a.explanation,
finalState: a.finalState,
explanationError: a.explanationError,
};
});
return { outcome, artifacts };
}
const kinds = new Map<string, number>();
const ruleTypes = new Set<string>();
const count = (key: string) => kinds.set(key, (kinds.get(key) ?? 0) + 1);
let slowest = { seed: 0, ms: 0 };
function crossesYearAndHoliday(request: Req): void {
const { start, end } = request['horizon'] as { start: string; end: string };
if (start.slice(0, 4) !== end.slice(0, 4)) count('crosses-year');
for (let ms = Date.parse(start); ms <= Date.parse(end); ms += DAY)
if (
weekday(ms) &&
frenchPublicHolidaysForYear(new Date(ms).getUTCFullYear()).has(iso(ms))
) {
count('holiday-week');
return;
}
}
function assertSameAsFrozen(seed: number): void {
const request = generateRequest(seed);
const t0 = performance.now();
const current = run(calculatePlanningInternal, request);
const frozen = run(frozenCalculate, request);
const ms = performance.now() - t0;
if (ms > slowest.ms) slowest = { seed, ms: Math.round(ms) };
crossesYearAndHoliday(request);
count(current.outcome.kind);
for (const rule of request.rules) ruleTypes.add(String(rule['type']).toLowerCase());
if ((request['declaredDays'] as unknown[]).length) count('declared');
if (request.rules.some((r) => r['rewardDays'])) count('reward');
try {
expect(current).toStrictEqual(frozen);
} catch (error) {
throw new Error(
`Engine differs from frozen engine. seed=${seed}\nrequest=${JSON.stringify(request)}\n${
(error as Error).message
}`,
{ cause: error },
);
}
}
type AsyncEngine = typeof calculatePlanningAsyncInternal;
/** Cancels after a seed-derived step count: during preparation, mid-search, or never. */
function cancelAfter(seed: number, total: number): number {
const rnd = mulberry32(seed ^ 0x5bd1e995);
const roll = rnd();
if (roll < 0.1) return Math.floor(rnd() * total);
if (roll < 0.25) return Number.POSITIVE_INFINITY;
// Preparation is most of the steps; spend the rest of the budget on the search.
return Math.floor(total * (0.9 + rnd() * 0.1));
}
async function runCancelled(engine: AsyncEngine, request: Req, limit: number) {
const calls: unknown[] = [];
const artifacts: unknown[] = [];
const explanations: unknown[] = [];
const outcomes: unknown[] = [];
let polls = 0;
const outcome = await engine(
structuredClone(request),
(plan, state) => calls.push({ plan, state }),
({ timings: _timings, ...rest }) => artifacts.push(rest),
(o) => outcomes.push(o),
(...args) => explanations.push(args),
() => ++polls > limit,
);
return { outcome, calls, artifacts, explanations, outcomes, polls };
}
const cancelledKinds = new Map<string, number>();
let incumbents = 0;
async function assertCancelledSameAsFrozen(seed: number): Promise<void> {
const request = generateRequest(seed);
const total = (await runCancelled(calculatePlanningAsyncInternal, request, Infinity)).polls;
const limit = cancelAfter(seed, total);
const current = await runCancelled(calculatePlanningAsyncInternal, request, limit);
const frozen = await runCancelled(frozenCalculateAsync, request, limit);
const kind = current.outcome.kind;
cancelledKinds.set(kind, (cancelledKinds.get(kind) ?? 0) + 1);
if (kind === 'PlanningIncomplete' && current.calls.length) incumbents++;
try {
expect(current).toStrictEqual(frozen);
} catch (error) {
throw new Error(
`Cancelled run differs from frozen engine. seed=${seed} limit=${limit}\nrequest=${JSON.stringify(request)}\n${
(error as Error).message
}`,
{ cause: error },
);
}
}
const ONLY_SEED = process.env['FROZEN_ENGINE_SEED'];
describe('frozen engine differential check', () => {
if (ONLY_SEED) {
it(`matches the frozen engine for seed ${ONLY_SEED}`, async () => {
assertSameAsFrozen(Number(ONLY_SEED));
await assertCancelledSameAsFrozen(Number(ONLY_SEED));
}, 120_000);
return;
}
for (let from = 0; from < CASES; from += CHUNK) {
it(`matches the frozen engine for seeds ${BASE_SEED + from}..${BASE_SEED + from + CHUNK - 1}`, () => {
for (let i = from; i < from + CHUNK; i++) assertSameAsFrozen(BASE_SEED + i);
}, 120_000);
}
for (let from = 0; from < CASES; from += CHUNK * 2) {
it(`matches the frozen engine when cancelled, seeds ${BASE_SEED + from}.. every 10th`, async () => {
for (let i = from; i < from + CHUNK * 2; i += 10)
await assertCancelledSameAsFrozen(BASE_SEED + i);
}, 300_000);
}
it('retains incumbents of cancelled calculations', () => {
console.info('cancelled coverage', Object.fromEntries(cancelledKinds), { incumbents });
expect(cancelledKinds.get('PlanningIncomplete') ?? 0).toBeGreaterThan(20);
expect(incumbents).toBeGreaterThan(10);
});
afterAll(() => {
console.info(
'frozen-engine coverage of generated cases',
Object.fromEntries(kinds),
[...ruleTypes],
'slowest case',
slowest,
);
});
it('generates every outcome kind and rule type', () => {
for (const kind of [
'Plan',
'PlanningConflict',
'InvalidInput',
'declared',
'reward',
'crosses-year',
'holiday-week',
])
expect(kinds.get(kind) ?? 0, kind).toBeGreaterThan(20);
expect(['if_then', 'optimization', 'time_window'].filter((t) => !ruleTypes.has(t))).toEqual(
[],
);
});
});
@@ -0,0 +1,22 @@
import type {
PlanningArtifactsCallback,
PlanningCheckpoint,
PlanningExplanationCallback,
PlanningOutcome,
PlanningOutcomeCallback,
} from '../leave-planner.core';
export function calculatePlanningInternal(
request: unknown,
checkpointCallback?: undefined,
artifactsCallback?: PlanningArtifactsCallback,
): PlanningOutcome;
export function calculatePlanningAsyncInternal(
request: unknown,
checkpointCallback?: PlanningCheckpoint,
artifactsCallback?: PlanningArtifactsCallback,
outcomeCallback?: PlanningOutcomeCallback,
explanationCallback?: PlanningExplanationCallback,
isCancelled?: () => boolean,
): Promise<PlanningOutcome>;
File diff suppressed because it is too large. Load diff
+113 -298
View File
@@ -2,8 +2,24 @@ import {
DeclaredNonWorkingDay,
parseDeclaredDays,
frenchPublicHolidaysForYear,
isWorkingPlanningDate,
} from './declared-non-working-days';
import {
addDays,
compareDate,
dateRange,
evaluateStateOptimization,
expiryTieKey,
isAtRisk,
optimizationBalanceObservations,
optimizationObservations,
periodData,
derivePlacements,
notePlacementAdded,
notePlacementRemoved,
placementTieKey,
utcDate,
workSequenceDates,
} from './plan-observations';
import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date';
import {
compareObjectiveLists,
@@ -501,21 +517,6 @@ const occurrenceCache = new WeakMap<SearchContext, Map<string, string[]>>();
const calendarPatternCache = new WeakMap<SearchContext, Map<string, string[][]>>();
const preferencePatternCache = new WeakMap<SearchContext, Map<string, string[][][]>>();
const declaredWindowCache = new WeakMap<SearchContext, Map<string, DeclaredNonWorkingDay[]>>();
type CalendarOptimizationObservations = Partial<
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
>;
const calendarObservationCache = new WeakMap<
SearchContext,
Map<string, CalendarOptimizationObservations>
>();
interface CalendarPeriod {
start: string;
end: string;
duration: number;
nonWorkingDates: readonly string[];
declaredDates: readonly string[];
}
const calendarPeriodCache = new WeakMap<SearchContext, Map<string, CalendarPeriod>>();
interface SearchContext {
declaredDays: readonly DeclaredNonWorkingDay[];
@@ -587,7 +588,6 @@ class UserCancelled extends Error {}
// A true value asks the asynchronous driver to pause immediately at a phase boundary.
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
const DAY_MS = 86_400_000;
// ponytail: cap the number of feasible obligation branches previewed for a
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
// that this ceiling misses a better lexicographic assignment.
@@ -635,29 +635,10 @@ function recordValue(value: unknown): RuntimeRecord | undefined {
return typeof value === 'object' && value !== null ? (value as RuntimeRecord) : undefined;
}
function utcDate(key: string): Date {
return new Date(`${key}T00:00:00Z`);
}
function addDays(key: string, days: number): string {
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
}
function compareDate(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function compareText(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function dateRange(start: string, endExclusive: string): string[] {
const result: string[] = [];
for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1))
result.push(date);
return result;
}
function defaultHorizon(start: string): { start: string; end: string } {
return { start, end: addPlanningYears(start, 1) };
}
@@ -720,19 +701,21 @@ function reason(code: string, message: string, ruleIds?: string[]): PlanningReas
}
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);
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: clonedPlacements,
earnedActivations: state.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
@@ -1482,6 +1465,7 @@ function assignDateVariants(
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, {
date,
balanceId: target.id,
@@ -1520,6 +1504,7 @@ function assignDateToBalance(
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, {
date,
balanceId,
@@ -1860,6 +1845,7 @@ function releasePlacement(state: State, date: string): State {
})),
};
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;
@@ -2364,6 +2350,71 @@ function* timeOffPeriodPlacementCandidates(
}
}
type FinalizeInvariance = 'exact' | 'without-earning' | 'unknown';
const finalizeInvarianceCache = new WeakMap<SearchContext, FinalizeInvariance[]>();
/**
* Finalizing never moves or retypes placements; it only edits ruleIds, supporting, and earned
* activations/balances. Preference results and the calendar optimizations (public-holiday-weeks,
* time-off-periods, work-sequences) read only placement dates and types, so the pre-finalize
* value is already the final one. leave-balances is exact only when nothing can be earned (no
* rewarding if_then rule and no earned activation on the candidate). leave-placements reads
* `supporting`, which finalizing rewrites. Entries follow activeObjectives order.
*/
function finalizeInvariance(context: SearchContext): FinalizeInvariance[] {
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:
// New element kinds stay unpruned until their bound is argued.
return 'unknown';
}
});
finalizeInvarianceCache.set(context, entries);
}
return entries;
}
/** True when the first differing finalize-invariant protected objective is already worse than the baseline. */
function provablyWorseThanProtectedPrefix(
candidate: State,
context: SearchContext,
throughIndex: number,
baseline: readonly PlanningObjective[],
): boolean {
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: State,
candidate: State | null,
@@ -2374,6 +2425,8 @@ function evaluateRuleCandidate(
incumbentVector = objectiveVector(before, context),
): { state: State; vector?: PlanningObjectiveVector } | undefined {
if (!candidate) return undefined;
if (provablyWorseThanProtectedPrefix(candidate, context, throughIndex, baseline))
return undefined;
const state = finalizeRuleCandidate(candidate, context, before);
if (!state) return undefined;
const active: PlanningObjective[] = [];
@@ -2833,7 +2886,7 @@ function* optimizeRulePlacements(
moved &&
dateInvariantObjectives &&
compareObjectiveLists(
tieBreakObjectives(moved),
tieBreakObjectives(moved, context),
beforeVector.objectives.slice(-2),
) >= 0
) {
@@ -3318,20 +3371,6 @@ function preferenceStateKey(state: State): string {
]);
}
function placementTieKey(state: State): string {
return [...state.placements.keys()].sort().join('|');
}
function expiryTieKey(state: State): string {
return [...state.placements.values()]
.map(
(placement) =>
state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? '',
)
.sort(compareDate)
.join('|');
}
function activeObjectiveVector(
state: State,
context: SearchContext,
@@ -3382,7 +3421,7 @@ function* activeObjectives(
}
}
function tieBreakObjectives(state: State): PlanningObjective[] {
function tieBreakObjectives(state: State, context: SearchContext): PlanningObjective[] {
return [
{
id: 'balance-expiry',
@@ -3393,7 +3432,7 @@ function tieBreakObjectives(state: State): PlanningObjective[] {
{
id: 'placement-date',
direction: 'minimize',
value: placementTieKey(state),
value: placementTieKey(state, context),
label: 'Placement date tie-break',
},
];
@@ -3403,7 +3442,10 @@ function obligationPreviewObjectives(
preview: { state: State },
context: SearchContext,
): PlanningObjective[] {
return [...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state)];
return [
...activeObjectiveVector(preview.state, context),
...tieBreakObjectives(preview.state, context),
];
}
/** Compare complete previews so obligation DFS does not commit to its first feasible branch. */
@@ -3418,237 +3460,6 @@ function compareObligationPreviews(
);
}
function isAtRisk(balance: InternalBalance, context: SearchContext): boolean {
return (
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
compareDate(balance.expiryDate, context.horizon.end) <= 0
);
}
function isNaturalNonWorkingDate(date: string, context: SearchContext): boolean {
return context.calendarIndex.has(date)
? !context.workingCalendar.has(date)
: !isWorkingPlanningDate(date);
}
function calendarDateAt(index: number, context: SearchContext): string {
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
}
function periodBounds(state: State, context: SearchContext) {
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: string[][] = [];
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: number,
endIndex: number,
context: SearchContext,
): CalendarPeriod {
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: State, context: SearchContext): TimeOffPeriod[] {
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: State, context: SearchContext) {
return state.balances.map((balance) => ({
unplacedQuantity: balance.remaining,
atRisk: isAtRisk(balance, context),
}));
}
function optimizationObservations(
state: State,
context: SearchContext,
periods?: TimeOffPeriod[],
): OptimizationObservations {
// Calendar facts depend only on occupied dates within this immutable request.
// Balance quantities, provenance and supporting status stay outside this cache.
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: ReturnType<typeof periodBounds> | undefined;
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: ReturnType<typeof periodBounds>,
context: SearchContext,
): OptimizationObservations['workSequences'] {
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: State, context: SearchContext) {
const sequences: string[][] = [];
let current: string[] = [];
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: OptimizationConfiguration,
state: State,
context: SearchContext,
observations = optimizationObservations(state, context),
): number {
return evaluateOptimization(configuration, observations);
}
function qualityMetrics(
state: State,
context: SearchContext,
@@ -3820,7 +3631,7 @@ function objectiveVector(
label: 'Unsatisfied obligations',
},
...active,
...tieBreakObjectives(state),
...tieBreakObjectives(state, context),
],
};
}
@@ -3959,6 +3770,7 @@ function planExplanation(
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,
@@ -5242,7 +5054,10 @@ export function analyzeCounterfactualInternal(
const baseline = cloneState(finalState);
const removeDate = selected.date;
const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
if (removeDate) baseline.placements.delete(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++;
const beforeState = baseline;
+198
View File
@@ -0,0 +1,198 @@
import fc from 'fast-check';
import { describe, expect, it } from 'vitest';
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
import {
addDays,
compareDate,
derivePlacements,
notePlacementAdded,
notePlacementRemoved,
observationsFromScratch,
optimizationObservations,
periodData,
placementTieKey,
setObservationShadowMode,
utcDate,
type ObservationCalendar,
type ObservedPlacement,
type ObservedPlan,
} from './plan-observations';
function calendar(start: string, length: number, declared: readonly string[]): ObservationCalendar {
const end = addDays(start, length);
const first = addDays(start, -400);
const last = addDays(end, 400);
const holidays = new Set<string>();
for (let year = Number(first.slice(0, 4)); year <= Number(last.slice(0, 4)); year++)
for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday);
const isWorking = (date: string) =>
![0, 6].includes(utcDate(date).getUTCDay()) && !holidays.has(date);
const calendarDays: string[] = [];
const calendarIndex = new Map<string, number>();
const workingCalendar = new Set<string>();
const workingDays: string[] = [];
const workingPrefix = [0];
for (let date = first; compareDate(date, last) < 0; date = addDays(date, 1)) {
calendarIndex.set(date, calendarDays.length);
calendarDays.push(date);
const working = isWorking(date);
if (working) {
workingCalendar.add(date);
if (date >= start && date < end) workingDays.push(date);
}
workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working));
}
const weeks = new Map<string, { dates: string[]; value: number }>();
for (const holiday of holidays) {
const weekday = utcDate(holiday).getUTCDay();
if (holiday < start || holiday >= end || weekday < 1 || weekday > 5) continue;
const monday = addDays(holiday, 1 - weekday);
const dates = [0, 1, 2, 3, 4].map((i) => addDays(monday, i)).filter(isWorking);
weeks.set(monday, { dates, value: 1 + (weekday % 3) });
}
return {
horizon: { start, end },
declaredDates: new Set(declared),
holidayWeeks: [...weeks.values()],
calendarDays,
calendarIndex,
workingCalendar,
workingPrefix,
workingDays,
check: () => {},
};
}
type Op = { kind: 'add' | 'remove' | 'retype'; pick: number };
const scenario = fc.record({
// Horizons around year boundaries, Easter Monday, 1 May and Ascension 2024.
start: fc.constantFrom('2023-12-18', '2024-03-28', '2024-04-24', '2024-05-06', '2024-12-23'),
length: fc.integer({ min: 1, max: 45 }),
declared: fc.array(fc.integer({ min: -3, max: 48 }), { maxLength: 8 }),
ops: fc.array(
fc.record({
kind: fc.constantFrom<Op['kind']>('add', 'add', 'remove', 'retype'),
pick: fc.nat(1000),
}),
{ maxLength: 40 },
),
});
function observe(plan: ObservedPlan, context: ObservationCalendar) {
const observations = optimizationObservations(plan, context);
return {
tieKey: placementTieKey(plan, context),
periods: periodData(plan, context),
publicHolidayWeeks: observations.publicHolidayWeeks,
timeOffPeriods: observations.timeOffPeriods,
workSequences: observations.workSequences,
};
}
function expectScratch(plan: ObservedPlan, context: ObservationCalendar) {
const scratch = observationsFromScratch(plan, context);
expect(observe(plan, context)).toEqual({
tieKey: scratch.tieKey,
periods: scratch.periods,
publicHolidayWeeks: scratch.publicHolidayWeeks,
timeOffPeriods: scratch.timeOffPeriods,
workSequences: scratch.workSequences,
});
}
const placement = (date: string, balanceId: string, supporting = false): ObservedPlacement => ({
date,
balanceId,
supporting,
});
describe('incremental plan observations', () => {
it('match the from-scratch computation after every command', () => {
fc.assert(
fc.property(scenario, ({ start, length, declared, ops }) => {
const context = calendar(
start,
length,
declared.map((offset) => addDays(start, offset)),
);
let plan: ObservedPlan = { balances: [], placements: new Map() };
expectScratch(plan, context);
ops.forEach(({ kind, pick }, step) => {
const parent = plan.placements as Map<string, ObservedPlacement>;
// Every fourth add may hit a weekend, holiday, declared date or a date up to three
// days outside the horizon, as snapshots can.
const free = (
pick % 4
? context.workingDays.filter((date) => !context.declaredDates.has(date))
: context.calendarDays.filter(
(date) =>
date >= addDays(context.horizon.start, -3) &&
date < addDays(context.horizon.end, 3),
)
).filter((date) => !parent.has(date));
const used = [...parent.keys()];
const child = new Map(parent);
if (kind === 'add' && free.length) {
const date = free[pick % free.length];
notePlacementAdded(parent, child, date);
child.set(date, placement(date, `b${step}`));
} else if (kind === 'remove' && used.length) {
const date = used[pick % used.length];
notePlacementRemoved(parent, child, date);
child.delete(date);
} else if (kind === 'retype' && used.length) {
const date = used[pick % used.length];
derivePlacements(parent, child);
child.set(date, placement(date, `r${step}`, true));
} else return;
plan = { balances: [], placements: child };
expectScratch(plan, context);
});
}),
{ numRuns: 300 },
);
});
it('resolves a chain of unobserved derivations', () => {
const context = calendar('2024-03-28', 20, ['2024-04-04']);
const root = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
expectScratch({ balances: [], placements: root }, context);
let parent = root;
for (const date of ['2024-04-03', '2024-04-05', '2024-04-08']) {
const child = new Map(parent);
notePlacementAdded(parent, child, date);
child.set(date, placement(date, 'b'));
parent = child;
}
const child = new Map(parent);
notePlacementRemoved(parent, child, '2024-04-03');
child.delete('2024-04-03');
expectScratch({ balances: [], placements: child }, context);
});
it('derives a child from an observed parent', () => {
const context = calendar('2024-03-28', 20, []);
const parent = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
expectScratch({ balances: [], placements: parent }, context);
const child = new Map(parent);
notePlacementAdded(parent, child, '2024-04-03');
child.set('2024-04-03', placement('2024-04-03', 'b'));
expectScratch({ balances: [], placements: child }, context);
});
});
describe('observation shadow mode', () => {
it('throws when a placements Map is mutated in place after being observed', () => {
const context = calendar('2024-03-28', 20, []);
const placements = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
const plan: ObservedPlan = { balances: [], placements };
setObservationShadowMode(true);
try {
optimizationObservations(plan, context);
placements.set('2024-04-10', placement('2024-04-10', 'b'));
expect(() => optimizationObservations(plan, context)).toThrow(/shadow mismatch/);
} finally {
setObservationShadowMode(false);
}
});
});
+693
View File
@@ -0,0 +1,693 @@
import { isWorkingPlanningDate } from './declared-non-working-days';
import type { TimeOffPeriod } from './leave-planner.core';
import {
evaluateOptimization,
type OptimizationConfiguration,
type OptimizationObservations,
} from './planning-objectives';
// Everything planning objectives observe of a plan: periods, work sequences, holiday weeks, balances, placements and tie keys.
export interface ObservedBalance {
id: string;
remaining: number;
expiryDate: string;
}
export interface ObservedPlacement {
date: string;
balanceId: string;
supporting: boolean;
}
export interface ObservedPlan<P extends ObservedPlacement = ObservedPlacement> {
balances: readonly ObservedBalance[];
placements: ReadonlyMap<string, P>;
}
export interface ObservationCalendar {
horizon: { start: string; end: string };
declaredDates: ReadonlySet<string>;
holidayWeeks: readonly { dates: readonly string[]; value: number }[];
calendarDays: readonly string[];
calendarIndex: ReadonlyMap<string, number>;
workingCalendar: ReadonlySet<string>;
workingPrefix: readonly number[];
workingDays: readonly string[];
check: () => void;
}
const DAY_MS = 86_400_000;
export function utcDate(key: string): Date {
return new Date(`${key}T00:00:00Z`);
}
export function addDays(key: string, days: number): string {
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
}
export function compareDate(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
export function dateRange(start: string, endExclusive: string): string[] {
const result: string[] = [];
for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1))
result.push(date);
return result;
}
type CalendarOptimizationObservations = Partial<
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
>;
interface CalendarPeriod {
start: string;
end: string;
duration: number;
nonWorkingDates: readonly string[];
declaredDates: readonly string[];
}
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<number, CalendarPeriod>>();
interface Group {
anchors: readonly string[];
startIndex: number;
endIndex: number;
period?: CalendarPeriod;
timeOff?: { readonly nonWorkingDates: readonly string[] };
}
// Immutable view of one placements Map's key set, valid for one calendar.
interface Occupancy {
calendar: ObservationCalendar;
dates: readonly string[];
groups: readonly Group[];
tieKey?: string;
observed: CalendarOptimizationObservations;
}
interface Derivation {
parent: ReadonlyMap<string, unknown>;
op: 'same' | 'add' | 'remove';
date?: string;
}
const occupancies = new WeakMap<ReadonlyMap<string, unknown>, Occupancy>();
const derivations = new WeakMap<ReadonlyMap<string, unknown>, Derivation>();
let shadowMode =
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[
'VAC_OBSERVATION_SHADOW'
] === '1';
export function setObservationShadowMode(enabled: boolean): void {
shadowMode = enabled;
}
function note(
parent: ReadonlyMap<string, unknown>,
child: ReadonlyMap<string, unknown>,
op: Derivation['op'],
date?: string,
): void {
// A stale occupancy of the child would describe its key set before this change.
occupancies.delete(child);
if (parent === child) return;
derivations.set(child, { parent, op, date });
}
/** `child` is a copy of `parent` with the same key set (placement objects may differ). */
export function derivePlacements(
parent: ReadonlyMap<string, unknown>,
child: ReadonlyMap<string, unknown>,
): void {
note(parent, child, 'same');
}
/** Call before `child.set(date, ...)` where `child` is a fresh copy of `parent` lacking `date`. */
export function notePlacementAdded(
parent: ReadonlyMap<string, unknown>,
child: ReadonlyMap<string, unknown>,
date: string,
): void {
note(parent, child, 'add', date);
}
/** Call before `child.delete(date)` where `child` is a fresh copy of `parent` holding `date`. */
export function notePlacementRemoved(
parent: ReadonlyMap<string, unknown>,
child: ReadonlyMap<string, unknown>,
date: string,
): void {
note(parent, child, 'remove', date);
}
function insertionPoint(sorted: readonly string[], date: string): number {
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: ReadonlyMap<string, unknown>,
context: ObservationCalendar,
): Occupancy {
const dates = [...placements.keys()].sort();
return { calendar: context, dates, groups: groupsFromScratch(dates, context), observed: {} };
}
function deriveOccupancy(
parent: Occupancy,
{ op, date }: Derivation,
context: ObservationCalendar,
): Occupancy | undefined {
if (op === 'same') return parent;
const at = insertionPoint(parent.dates, date!);
const present = parent.dates[at] === date;
if (present === (op === 'add')) return undefined;
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!);
// Group merging and splitting assume a working day; snapshots may hold any date.
if (touchesGroups && !context.workingCalendar.has(date!)) return undefined;
const groups = !touchesGroups
? parent.groups
: op === 'add'
? groupsWithDate(parent.groups, date!, context)
: groupsWithoutDate(parent.groups, date!, context);
if (!groups) return undefined;
const observed: CalendarOptimizationObservations = {};
if (parent.observed.publicHolidayWeeks)
observed.publicHolidayWeeks = derivedHolidayWeeks(
parent.observed.publicHolidayWeeks,
dates,
date!,
context,
);
return { calendar: context, dates, groups, observed };
}
const weeksByDate = new WeakMap<ObservationCalendar, Map<string, number[]>>();
function derivedHolidayWeeks(
weeks: NonNullable<CalendarOptimizationObservations['publicHolidayWeeks']>,
dates: readonly string[],
date: string,
context: ObservationCalendar,
) {
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: readonly Group[],
date: string,
context: ObservationCalendar,
): readonly Group[] {
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: Group = {
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: readonly Group[],
date: string,
context: ObservationCalendar,
): readonly Group[] | undefined {
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 undefined;
const cut = insertionPoint(group.anchors, date);
if (group.anchors[cut] !== date) return undefined;
const leftAnchors = group.anchors.slice(0, cut);
const rightAnchors = group.anchors.slice(cut + 1);
const split: Group[] = [];
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: ReadonlyMap<string, unknown>,
context: ObservationCalendar,
): Occupancy {
// Walk the record chain up to the nearest materialized ancestor, then derive back down.
const chain: [ReadonlyMap<string, unknown>, Derivation][] = [];
let current = placements;
let occupancy: Occupancy | undefined;
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: Occupancy): string {
return (occupancy.tieKey ??= occupancy.dates.join('|'));
}
export function placementTieKey(state: ObservedPlan, context: ObservationCalendar): string {
const occupancy = occupancyOf(state.placements, context);
if (shadowMode) verifyOccupancy(state, context, occupancy);
return tieKeyOf(occupancy);
}
export function expiryTieKey(state: ObservedPlan): string {
return [...state.placements.values()]
.map(
(placement) =>
state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? '',
)
.sort(compareDate)
.join('|');
}
export function isAtRisk(balance: ObservedBalance, context: ObservationCalendar): boolean {
return (
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
compareDate(balance.expiryDate, context.horizon.end) <= 0
);
}
function isNaturalNonWorkingDate(date: string, context: ObservationCalendar): boolean {
return context.calendarIndex.has(date)
? !context.workingCalendar.has(date)
: !isWorkingPlanningDate(date);
}
function calendarDateAt(index: number, context: ObservationCalendar): string {
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
}
function extendStart(index: number, context: ObservationCalendar): number {
while (
context.declaredDates.has(calendarDateAt(index - 1, context)) ||
isNaturalNonWorkingDate(calendarDateAt(index - 1, context), context)
) {
context.check();
index--;
}
return index;
}
function extendEnd(index: number, context: ObservationCalendar): number {
while (
context.declaredDates.has(calendarDateAt(index + 1, context)) ||
isNaturalNonWorkingDate(calendarDateAt(index + 1, context), context)
) {
context.check();
index++;
}
return index;
}
function groupsFromScratch(
placementDates: Iterable<string>,
context: ObservationCalendar,
): Group[] {
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: string[][] = [];
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: number,
endIndex: number,
context: ObservationCalendar,
): CalendarPeriod {
let periods = calendarPeriodCache.get(context);
if (!periods) {
periods = new Map();
calendarPeriodCache.set(context, periods);
}
const key = startIndex * 1_048_576 + 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: ObservedPlan,
groups: readonly Group[],
context: ObservationCalendar,
): TimeOffPeriod[] {
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,
};
});
}
export function periodData(state: ObservedPlan, context: ObservationCalendar): TimeOffPeriod[] {
const occupancy = occupancyOf(state.placements, context);
if (shadowMode) verifyOccupancy(state, context, occupancy);
return periodsOf(state, occupancy.groups, context);
}
/** Reference computation, ignoring every cache: the test oracle and the shadow-mode baseline. */
export function observationsFromScratch(state: ObservedPlan, context: ObservationCalendar) {
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: ObservedPlan, context: ObservationCalendar) {
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: readonly Group[],
context: ObservationCalendar,
periods?: TimeOffPeriod[],
) {
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: readonly Group[], context: ObservationCalendar) {
return Object.freeze(
workSequenceLengths(groups, context).map((sequence) => Object.freeze(sequence)),
);
}
function calendarObservations(
state: ObservedPlan,
groups: readonly Group[],
context: ObservationCalendar,
) {
return {
publicHolidayWeeks: holidayWeeksFor(state, context),
timeOffPeriods: timeOffPeriodsFor(groups, context),
workSequences: workSequencesFor(groups, context),
};
}
function verifyOccupancy(
state: ObservedPlan,
context: ObservationCalendar,
occupancy: Occupancy,
): void {
const expected = observationsFromScratch(state, context);
const fail = (field: string, got: unknown, want: unknown): never => {
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'] as const) {
const cached = occupancy.observed[field];
// Caller-supplied periods are never compared: they are not derived from the key set.
if (cached && JSON.stringify(cached) !== JSON.stringify(expected[field]))
fail(field, cached, expected[field]);
}
}
export function optimizationBalanceObservations(state: ObservedPlan, context: ObservationCalendar) {
return state.balances.map((balance) => ({
unplacedQuantity: balance.remaining,
atRisk: isAtRisk(balance, context),
}));
}
export function optimizationObservations(
state: ObservedPlan,
context: ObservationCalendar,
periods?: TimeOffPeriod[],
): OptimizationObservations {
// Calendar facts depend only on occupied dates within this immutable request.
// Balance quantities, provenance and supporting status stay outside this cache.
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: readonly Group[],
context: ObservationCalendar,
): OptimizationObservations['workSequences'] {
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;
}
export function workSequenceDates(state: ObservedPlan, context: ObservationCalendar) {
const sequences: string[][] = [];
let current: string[] = [];
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,
}));
}
export function evaluateStateOptimization(
configuration: OptimizationConfiguration,
state: ObservedPlan,
context: ObservationCalendar,
observations = optimizationObservations(state, context),
): number {
return evaluateOptimization(configuration, observations);
}
+2
View File
@@ -3,6 +3,7 @@
import angular from '@analogjs/vite-plugin-angular';
import { defineConfig } from 'vite';
import { coverageConfigDefaults } from 'vitest/config';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
@@ -18,6 +19,7 @@ export default defineConfig(({ mode }) => {
coverage: {
provider: 'istanbul',
reporter: ['text', 'html', 'cobertura'],
exclude: [...coverageConfigDefaults.exclude, '**/frozen-engine/**'],
},
},
define: {