Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f5e4f8bc2 | ||
|
|
40a11c245a | ||
|
|
3bbe989eca | ||
|
|
cfdb28c90a | ||
|
|
63b50c4311 | ||
|
|
acdb19352a | ||
|
|
68a9e33de6 | ||
|
|
7890a03a74 | ||
|
|
87d72064be | ||
|
|
24c35542a8 | ||
|
|
4d26e65672 | ||
|
|
a871f8f28c | ||
|
|
a371e03462 | ||
|
|
1711bc570c | ||
|
|
c6610cd633 | ||
|
|
433a8b0ade | ||
|
|
c7e7abcd24 | ||
|
|
cbde23f572 | ||
|
|
0852cd4444 |
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
@@ -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`.
|
||||
@@ -76,8 +76,6 @@ jobs:
|
||||
name: benchmark
|
||||
runs-on: ubuntu-latest
|
||||
container: node:24.21.0
|
||||
# shortcut: advisory while #78 is open; restore the gate after the protocol passes.
|
||||
continue-on-error: true
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=2048
|
||||
steps:
|
||||
@@ -87,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
|
||||
@@ -148,7 +146,7 @@ jobs:
|
||||
|
||||
images:
|
||||
name: images
|
||||
needs: [policy, validate, test, build, security]
|
||||
needs: [policy, validate, test, benchmark, build, security]
|
||||
if: >-
|
||||
success() &&
|
||||
(github.event_name == 'push' ||
|
||||
@@ -192,17 +190,17 @@ jobs:
|
||||
required:
|
||||
name: required
|
||||
if: ${{ always() }}
|
||||
needs: [policy, validate, test, build, security, images]
|
||||
needs: [policy, validate, test, benchmark, build, security, images]
|
||||
runs-on: ubuntu-latest
|
||||
container: node:24.21.0
|
||||
env:
|
||||
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.build.result }} ${{ needs.security.result }}
|
||||
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.benchmark.result }} ${{ needs.build.result }} ${{ needs.security.result }}
|
||||
IMAGE_RESULT: ${{ needs.images.result }}
|
||||
IMAGE_EXPECTED: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
steps:
|
||||
- name: Require successful validation and expected publication
|
||||
run: |
|
||||
test "$CHECK_RESULTS" = 'success success success success success'
|
||||
test "$CHECK_RESULTS" = 'success success success success success success' || exit 1
|
||||
if [ "$IMAGE_EXPECTED" = 'true' ]; then
|
||||
test "$IMAGE_RESULT" = 'success'
|
||||
else
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
.scratch/
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
|
||||
@@ -224,7 +224,7 @@ Open <http://localhost:4200>. The development server reloads when source files c
|
||||
|
||||
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, a loaded year, and two- and three-year horizons with dated annual balances. It reports preparation time, planning latency, planning-work time, total wall time through explanation delivery, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, and determinism.
|
||||
|
||||
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or multi-year duration ceiling is established; timing measurements are release evidence, not CI assertions.
|
||||
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or individual multi-year duration ceiling is established. CI requires the complete seven-scenario Node protocol to finish within 300 seconds; individual scenario timings remain diagnostic evidence.
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -241,7 +241,7 @@ The image builds the Angular application with Bun and Node, then runs the genera
|
||||
|
||||
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, application/test type checks, Vitest coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
|
||||
|
||||
Functional tests use one worker and a 2 GiB Node heap. The separate benchmark job runs without coverage and keeps its five-minute protocol timeout. At the user's request, it has `continue-on-error: true` while [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) is open. #78 must restore the benchmark gate after the protocol passes. Passing functional checks during this exception does not establish benchmark acceptance.
|
||||
Functional tests use one worker and a 2 GiB Node heap. The separate required benchmark job runs without coverage and keeps its five-minute protocol timeout, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restores this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). Benchmark success is required for image publication and the aggregate `required` job. The narrow local timing margin still needs confirmation on the CI runner.
|
||||
|
||||
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@angular/platform-browser-dynamic": "22.2.1",
|
||||
"@angular/platform-server": "22.2.1",
|
||||
"@angular/router": "22.2.1",
|
||||
"@angular/ssr": "22.2.2",
|
||||
"@angular/ssr": "22.2.3",
|
||||
"date-holidays": "^3.30.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"express": "^5.2.1",
|
||||
@@ -27,8 +27,8 @@
|
||||
"@analogjs/platform": "^2.7.5",
|
||||
"@analogjs/vite-plugin-angular": "^2.7.5",
|
||||
"@analogjs/vitest-angular": "^2.7.5",
|
||||
"@angular/build": "22.2.2",
|
||||
"@angular/cli": "22.2.2",
|
||||
"@angular/build": "22.2.3",
|
||||
"@angular/cli": "22.2.3",
|
||||
"@angular/compiler-cli": "22.2.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nx/devkit": "^23.0.0",
|
||||
@@ -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",
|
||||
@@ -65,19 +66,19 @@
|
||||
|
||||
"@analogjs/vitest-angular": ["@analogjs/vitest-angular@2.8.0", "", { "peerDependencies": { "@analogjs/vite-plugin-angular": "*", "@angular-devkit/architect": ">=0.1700.0 < 0.2300.0 || >=0.2200.0 < 0.2300.0", "@angular-devkit/schematics": ">=17.0.0", "vitest": "^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "zone.js": ">=0.14.0" }, "optionalPeers": ["zone.js"] }, "sha512-ZNwjOf8ACEOW8yTC9iAu3uXt8q0GABgNXoo9lBUN0RuV7SOubpcabZewirETP5NqgyhJ5S96Ql1WFeW/jAznqA=="],
|
||||
|
||||
"@angular-devkit/architect": ["@angular-devkit/architect@0.2202.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-WGRAxFj+R9LC8HuK3DnDgxHo/XrgfklNQeNgjq/phTEVq5ZdWxc8kDoPA4uQz4vF+HY2eycAAtOPfMvj2TBEiQ=="],
|
||||
"@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
|
||||
|
||||
"@angular-devkit/core": ["@angular-devkit/core@22.2.2", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-iQexb5xUON9AkTzvBiMftrHWLguIlYv6M/BOH/sj7U5L8eOPMu8N269WF74xPo4n/X5tusFLqniGI0zolT2Sjg=="],
|
||||
"@angular-devkit/core": ["@angular-devkit/core@22.2.3", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-bi1oBjJeg1XCgRrBYSuJikaJV4sO/mcnyFE/0a1H646yHhQpVYoU/M2Vh69Qv8h+YoMfC7RuSxT4LZHaubhhLw=="],
|
||||
|
||||
"@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.1", "", { "dependencies": { "@angular-devkit/core": "22.2.1", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-frHhFwu2TEfTZX5jfAYL2bztuG5mGggfJhiM9bo5/QtLAi49LtRbJxIktsu6vjJdujxeB2TKD7KqnXwNYgd41Q=="],
|
||||
"@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
|
||||
|
||||
"@angular/animations": ["@angular/animations@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.1" } }, "sha512-vTxZN6uL/m61lv7ZCvfmQ3VuFb4o/tniGLpc3SpkNHE6MLM8WN0IjfNBRLMYMovd1uUBSPwhcG4Llcmucr9dmw=="],
|
||||
|
||||
"@angular/build": ["@angular/build@22.2.2", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.2", "@babel/core": "8.0.5", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.5.4", "browserslist": "^4.26.0", "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "magic-string": "1.4.1", "mrmime": "2.0.1", "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.8", "sass": "1.104.1", "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.3.0", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.2.2", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "rollup": "^4.0.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8 || ^5.0.0" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "rollup", "tailwindcss", "vitest"] }, "sha512-QHJWkZdEWTqqb3+HiSp8lnuS4iQJCa7yTK9gg5TaMIjCoZzP9mD9L417stiVDZGj2J3Y9mrml044nEVvSbW0ig=="],
|
||||
"@angular/build": ["@angular/build@22.2.3", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2202.3", "@babel/core": "8.0.5", "@inquirer/confirm": "6.3.2", "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.5.4", "browserslist": "^4.26.0", "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "magic-string": "1.4.1", "mrmime": "2.0.1", "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.8", "sass": "1.104.1", "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", "vite": "8.3.0", "xxhash-wasm": "1.1.0" }, "optionalDependencies": { "lmdb": "3.5.6" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.2.3", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "rollup": "^4.0.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8 || ^5.0.0" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "rollup", "tailwindcss", "vitest"] }, "sha512-EtNEuPBUCyIuzdXrQ/nINvgFzW8bUI5AkAZtJiSn7nsFOlvaygaKN0kENNTO3zRl3T9B4GDog+Ks5Nec3FBkhg=="],
|
||||
|
||||
"@angular/cdk": ["@angular/cdk@22.2.1", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-0vjlqYBbLif1EwHhKz2r4thTDvtRI2CKi2js9TzPRD0n4VlFUnAXaaW47s5TCCObIoYun6Bo1zZ0uPgwb+qEMw=="],
|
||||
|
||||
"@angular/cli": ["@angular/cli@22.2.2", "", { "dependencies": { "@angular-devkit/architect": "0.2202.2", "@angular-devkit/core": "22.2.2", "@angular-devkit/schematics": "22.2.2", "@inquirer/prompts": "8.7.2", "@listr2/prompt-adapter-inquirer": "4.2.7", "@modelcontextprotocol/server": "2.3.1", "@schematics/angular": "22.2.2", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "npm-package-arg": "14.0.0", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.5", "yargs": "18.1.0", "zod": "4.6.5" }, "bin": { "ng": "bin/ng.js" } }, "sha512-AALIN+zai0CiA7HgSCGVPHyZyiw3yFWliCfbaS4ueHDJHVZ8QhVjRUrnfGZc3EJnigcyQ0w2DKeTxjwqpDjKew=="],
|
||||
"@angular/cli": ["@angular/cli@22.2.3", "", { "dependencies": { "@angular-devkit/architect": "0.2202.3", "@angular-devkit/core": "22.2.3", "@angular-devkit/schematics": "22.2.3", "@inquirer/prompts": "8.7.2", "@listr2/prompt-adapter-inquirer": "4.2.7", "@modelcontextprotocol/server": "2.3.1", "@schematics/angular": "22.2.3", "jsonc-parser": "3.3.1", "listr2": "11.1.0", "npm-package-arg": "14.0.0", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.8.5", "yargs": "18.1.0", "zod": "4.6.6" }, "bin": { "ng": "bin/ng.js" } }, "sha512-ObTX0DxJ0/PMl4pkUKI+uWPr7UC7Je94KpHP8l1lr3exhundapgr7VvNsozprJb1hMlOMDab6+quTReSYvF85Q=="],
|
||||
|
||||
"@angular/common": ["@angular/common@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-OqBH3EY4i8e3PvnYDugZGipSs1HWs2ZrqoClndKtFDS9mQULSSuLoxjG9nF1J/c+yUqhuyZlHjkXWS0n32pEfw=="],
|
||||
|
||||
@@ -99,7 +100,7 @@
|
||||
|
||||
"@angular/router": ["@angular/router@22.2.1", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.2.1", "@angular/core": "22.2.1", "@angular/platform-browser": "22.2.1", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-HJfJ4yA1znKd9YFtbDOJfKmD12x0PhS7LkmYdWlJRu2WkQZRBVrdGKdmbvV5UvrqCRllVWKpzw1TIZJM69rlcw=="],
|
||||
|
||||
"@angular/ssr": ["@angular/ssr@22.2.2", "", { "dependencies": { "beasties": "0.5.4", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0" }, "optionalPeers": ["@angular/platform-server"] }, "sha512-iMcwT5ZynIj33R0XSq+AtC5Ra6FGKOvV3qU0sjRr+lUaGfL6MjygPovPFsKLi8HzuGOA5dYDKffJfPedsC9Rxg=="],
|
||||
"@angular/ssr": ["@angular/ssr@22.2.3", "", { "dependencies": { "beasties": "0.5.4", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/router": "^22.0.0" }, "optionalPeers": ["@angular/platform-server"] }, "sha512-u/jeHCvSBMf9H72/rxrzON0UQLXux73pgft2Az4lgyhEgnFq+ttTUVoDSABcUoqZJKfh4pSTiCuFZJCpYB+xJQ=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@7.1.3", "", { "dependencies": { "@csstools/css-calc": "^3.4.2", "@csstools/css-color-parser": "^4.2.5", "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2", "lru-cache": "^11.5.3" } }, "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ=="],
|
||||
|
||||
@@ -837,7 +838,7 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="],
|
||||
|
||||
"@schematics/angular": ["@schematics/angular@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "@angular-devkit/schematics": "22.2.2", "jsonc-parser": "3.3.1", "oxc-parser": "0.150.0", "typescript": "6.0.3" } }, "sha512-JxpXKpxdmQcRsJbriWJOhB7HeVTzz9CEsPNw6fQ0LOuPFnvtyUIXLxyFbKv/LfoFUz8wFADCtNU5sjowME5Mvg=="],
|
||||
"@schematics/angular": ["@schematics/angular@22.2.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "@angular-devkit/schematics": "22.2.3", "jsonc-parser": "3.3.1", "oxc-parser": "0.150.0", "typescript": "6.0.3" } }, "sha512-DY8wv/LtNf3YD/EEoFdER4ihMyw5yywuOQEKasm4d2uBJ5peGIWV3LXJXXqD7b4WVC8xS1eUAjingyWfI9reHw=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -2375,19 +2378,19 @@
|
||||
|
||||
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
|
||||
|
||||
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||
"zod": ["zod@4.6.6", "", {}, "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA=="],
|
||||
|
||||
"@analogjs/vite-plugin-nitro/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
|
||||
"@angular-devkit/architect/@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
|
||||
"@angular-devkit/architect/@angular-devkit/core": ["@angular-devkit/core@22.2.2", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-iQexb5xUON9AkTzvBiMftrHWLguIlYv6M/BOH/sj7U5L8eOPMu8N269WF74xPo4n/X5tusFLqniGI0zolT2Sjg=="],
|
||||
|
||||
"@angular-devkit/core/source-map": ["source-map@0.8.0", "", {}, "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ=="],
|
||||
|
||||
"@angular-devkit/schematics/@angular-devkit/core": ["@angular-devkit/core@22.2.1", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-2oiuWVbUg7pE9kh2grp+MF2ynys+tMWZ/mmoYlqWrhMS2n5oV304ir0/IvvCCSGz9qsbL2A1vZhnlI2UbTSNiA=="],
|
||||
"@angular-devkit/schematics/@angular-devkit/core": ["@angular-devkit/core@22.2.2", "", { "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.7", "rxjs": "7.8.2", "source-map": "0.8.0" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-iQexb5xUON9AkTzvBiMftrHWLguIlYv6M/BOH/sj7U5L8eOPMu8N269WF74xPo4n/X5tusFLqniGI0zolT2Sjg=="],
|
||||
|
||||
"@angular-devkit/schematics/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
|
||||
|
||||
"@angular/build/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
|
||||
"@angular/build/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-/605FxTg8Kt5fqNuIBVZMfuiNwIf+CTNsiYnHY85/7OTqCNkEwdkXKc3DiFM2KlR/AV2g64GkvHgHqxHzYh34g=="],
|
||||
|
||||
"@angular/build/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
|
||||
|
||||
@@ -2395,9 +2398,9 @@
|
||||
|
||||
"@angular/build/vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
|
||||
|
||||
"@angular/cli/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g=="],
|
||||
"@angular/cli/@angular-devkit/architect": ["@angular-devkit/architect@0.2202.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "rxjs": "7.8.2" }, "bin": { "architect": "bin/cli.js" } }, "sha512-/605FxTg8Kt5fqNuIBVZMfuiNwIf+CTNsiYnHY85/7OTqCNkEwdkXKc3DiFM2KlR/AV2g64GkvHgHqxHzYh34g=="],
|
||||
|
||||
"@angular/cli/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
|
||||
"@angular/cli/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-+XfBViIGqiFQK8KJXA6PgANX9tLuMO/x8GFjlzEmA4LIe9ikAtpGZw+Ezo547BIJQoJBIKCJYR0knE4BmbFeSQ=="],
|
||||
|
||||
"@angular/compiler-cli/@babel/core": ["@babel/core@8.0.1", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-compilation-targets": "^8.0.0", "@babel/helpers": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", "semver": "^7.7.3" } }, "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw=="],
|
||||
|
||||
@@ -2761,6 +2764,10 @@
|
||||
|
||||
"@mapbox/node-pre-gyp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||
|
||||
"@modelcontextprotocol/core/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||
|
||||
"@modelcontextprotocol/server/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||
|
||||
"@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
@@ -2797,7 +2804,7 @@
|
||||
|
||||
"@rollup/pluginutils/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"@schematics/angular/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.2", "", { "dependencies": { "@angular-devkit/core": "22.2.2", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA=="],
|
||||
"@schematics/angular/@angular-devkit/schematics": ["@angular-devkit/schematics@22.2.3", "", { "dependencies": { "@angular-devkit/core": "22.2.3", "jsonc-parser": "3.3.1", "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" } }, "sha512-+XfBViIGqiFQK8KJXA6PgANX9tLuMO/x8GFjlzEmA4LIe9ikAtpGZw+Ezo547BIJQoJBIKCJYR0knE4BmbFeSQ=="],
|
||||
|
||||
"@schematics/angular/oxc-parser": ["oxc-parser@0.150.0", "", { "dependencies": { "@oxc-project/types": "^0.150.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.150.0", "@oxc-parser/binding-android-arm64": "0.150.0", "@oxc-parser/binding-darwin-arm64": "0.150.0", "@oxc-parser/binding-darwin-x64": "0.150.0", "@oxc-parser/binding-freebsd-x64": "0.150.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", "@oxc-parser/binding-linux-arm64-musl": "0.150.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-gnu": "0.150.0", "@oxc-parser/binding-linux-x64-musl": "0.150.0", "@oxc-parser/binding-openharmony-arm64": "0.150.0", "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg=="],
|
||||
|
||||
@@ -2949,6 +2956,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=="],
|
||||
|
||||
+135
-31
@@ -2,44 +2,148 @@
|
||||
|
||||
## Composable optimization rules: current verification status
|
||||
|
||||
The #70–#75 implementation uses explicit active optimization configurations in
|
||||
the benchmark fixtures. Before trace removal, the full functional coverage run passed 652 tests
|
||||
across 34 files under the 2 GB Node heap limit, excluding the separate benchmark.
|
||||
That run does not establish a passing performance protocol.
|
||||
[Issue #78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) passes the
|
||||
unchanged Node acceptance protocol on the documented development laptop:
|
||||
**298.6 seconds**, including one warm-up and two measured repetitions of all
|
||||
seven scenarios, without coverage, with one worker and a 2 GiB Node heap.
|
||||
The full protocol keeps a 600-second timeout only as a hang guard (see
|
||||
[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).
|
||||
|
||||
On 2026-10-15, the #76 worktree based on `ea42561` passed 630 functional tests
|
||||
across 33 files with coverage under the 2 GB Node heap limit. Complete outcome
|
||||
comparisons against that baseline matched for all seven benchmark scenarios.
|
||||
The separate, unchanged benchmark did not complete within its 300-second limit
|
||||
(external timeout exit 124). Performance acceptance and any further optimization
|
||||
remain with #78.
|
||||
The benchmark is required again in Gitea Actions, including the image and
|
||||
aggregate gates. [The #78 report](issue-78.md) records the baseline timeout,
|
||||
profiling, before/after diagnostics, full protocol output, correctness checks
|
||||
and browser limitations. That pass had only 1.4 seconds of external-timeout
|
||||
headroom. Three later exact optimizations for PR #278 cut the Vitest duration
|
||||
to **228.1 seconds** (71.9 seconds of headroom) in the most recent local run:
|
||||
[direct date keys](pr-278-date-keys.md),
|
||||
[immutable date counts](pr-278-immutable-date-counts.md) and
|
||||
[mandatory-move prechecks](pr-278-mandatory-moves.md). Outcomes, explanations
|
||||
and final states are unchanged. CI-runner performance and live workflow
|
||||
validation remain unverified.
|
||||
The existing migration protection stays in place under
|
||||
[issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268).
|
||||
|
||||
Before the Gitea migration, GitLab pipeline 320 passed all 652 functional tests
|
||||
in 109.43 seconds, then the seven-scenario protocol timed out at its unchanged
|
||||
300-second limit without an out-of-memory failure. In an earlier diagnostic run, the loaded-year warm-up took
|
||||
184.36 seconds and its first measured repetition began about 251.78 seconds into
|
||||
the protocol. Those diagnostic timings precede the final period-projection cache
|
||||
and the original-decision-trace removal in #76, and are not a result for either.
|
||||
Before these optimizations, the trace-free integrated baseline at `2f4af14`
|
||||
timed out after 300 seconds without a complete report. The #76 verification
|
||||
previously passed 630 functional tests across 33 files with coverage under the
|
||||
same heap setting, while its unchanged benchmark also timed out. Before the
|
||||
Gitea migration, GitLab pipeline 320 passed 652 functional tests, but its
|
||||
separate benchmark timed out as well. An earlier loaded-year warm-up took
|
||||
184.36 seconds; that historical sample predates the final period-projection
|
||||
cache and trace removal and is not a measurement of the current engine.
|
||||
|
||||
At the user's request, #75 finishes on saved-rule migration and functional
|
||||
verification. #78 owns benchmark verification and implementation of further
|
||||
optimizations when needed, after trace removal in #76 and integrated functional verification in #77. All seven scenarios, one warm-up and two measured repetitions per
|
||||
scenario, the 2 GB heap limit and the 300-second timeout remain required for #78.
|
||||
At the user's request, the Gitea CI configuration defines the benchmark in a
|
||||
separate job with `continue-on-error: true` while #78 is open. Functional coverage
|
||||
tests and all other required checks remain blocking. #78 must restore the required benchmark gate
|
||||
after the unchanged protocol passes. A green pipeline during this exception
|
||||
does not establish a passing benchmark. No automatic elapsed-time planning
|
||||
cutoff is introduced. Gitea Actions is enabled; live workflow validation is tracked in
|
||||
[issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268). The converted
|
||||
workflow preserves the separate advisory benchmark job. See the
|
||||
[CI migration status](../../README.md#continuous-integration).
|
||||
#75 owns functional saved-rule migration, #76 removes tracing, #77 verifies
|
||||
retained behavior and #78 owns performance acceptance. No automatic elapsed-time
|
||||
engine cutoff is introduced. No reference-phone run is claimed.
|
||||
|
||||
The results below document earlier implementations and protocols. They are
|
||||
historical measurements rather than the performance result for composable rules or trace removal. Their trace counts, timings and controls describe the former implementation only.
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,15 @@
|
||||
scenario repetition preparation_ms planning_ms wall_ms outcome last_stage placements deterministic max_ui_delay_ms planning_work_ms peak_main_thread_heap_bytes
|
||||
representative year 1 0.1 564.4 564.6 Plan dates 30 baseline 8.0 552.6 ""
|
||||
representative year 2 0.0 555.0 555.1 Plan dates 30 yes 8.1 544.4 ""
|
||||
typed obligations 1 0.1 5040.7 5040.8 Plan dates 10 baseline 11.3 5030.5 ""
|
||||
typed obligations 2 0.1 5141.2 5141.3 Plan dates 10 yes 12.9 5131.1 ""
|
||||
chained earned entitlements 1 0.1 29.0 29.1 Plan dates 3 baseline 0.0 21.6 ""
|
||||
chained earned entitlements 2 0.1 24.7 24.8 Plan dates 3 yes 0.0 17.4 ""
|
||||
deliberate conflict 1 0.1 13.0 13.2 PlanningConflict 0 baseline 0.0 3.1 ""
|
||||
deliberate conflict 2 0.0 12.6 12.7 PlanningConflict 0 yes 0.0 2.8 ""
|
||||
loaded year 1 0.1 76341.7 76341.8 Plan dates 72 baseline 16.8 76326.9 ""
|
||||
loaded year 2 0.1 79093.8 79094.0 Plan dates 72 yes 16.3 79074.9 ""
|
||||
two years 1 0.1 3391.7 3391.9 Plan dates 60 baseline 12.9 3375.3 ""
|
||||
two years 2 0.1 3282.6 3282.8 Plan dates 60 yes 10.9 3267.1 ""
|
||||
three years 1 0.1 11333.3 11333.5 Plan dates 90 baseline 13.9 11313.4 ""
|
||||
three years 2 0.1 11394.2 11394.3 Plan dates 90 yes 15.7 11368.4 ""
|
||||
|
@@ -0,0 +1,345 @@
|
||||
# Issue #78: integrated planner performance
|
||||
|
||||
Measurements on 2026-10-15/16 (Europe/Paris), starting from the trace-free
|
||||
integrated planner at `2f4af14869c47836d2c1008157136f75c9d3e98b`.
|
||||
[Issue #78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) supplies the
|
||||
acceptance criteria. The original spec, baseline source, profiles, diagnostic
|
||||
runners and complete local logs are retained in `/tmp/vac-issue-78`.
|
||||
|
||||
## Required protocol
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||
```
|
||||
|
||||
| Run | Result |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Unmodified baseline | External timeout, exit 124, 300.008 seconds; no complete report. |
|
||||
| Optimized planner | Exit 0, 298.551 seconds externally; Vitest reports 294.16 seconds, with 292.958 seconds in the test. |
|
||||
| Restart-interrupted optimized run | Stopped by the agent-server restart before a result; excluded from acceptance and timing comparisons. |
|
||||
|
||||
The passing run includes the existing typed-obligation precheck, all seven
|
||||
warm-ups, and two measured repetitions of every scenario. Coverage is disabled,
|
||||
the Node heap is limited to 2048 MiB and the full command retains its 300-second
|
||||
timeout. No other build, test or profiling workload ran alongside it.
|
||||
|
||||
[The complete 14-row report](issue-78-protocol.tsv) preserves every measured row.
|
||||
Every repetition has its expected outcome, placement count and final stage;
|
||||
each second repetition matches its first complete outcome. The deliberate
|
||||
conflict remains a conflict with zero placements. The other scenarios complete
|
||||
through `dates`, with 30, 10, 3, 72, 60 and 90 placements respectively.
|
||||
|
||||
The fixture module is byte-for-byte unchanged, SHA-256
|
||||
`16e057fa042c898d203f71e018f183c9fbecb5cd0edbf0ce0f3f25277cb00d3a`.
|
||||
The test change only prints its existing report; all correctness assertions,
|
||||
configurations, quotas, horizons, warm-ups and repetition counts remain intact.
|
||||
There are no trace counters, transfers or acknowledgement waits.
|
||||
|
||||
The passing command has only **1.449 seconds of external-timeout headroom**.
|
||||
This is a local pass, not a runner-speed guarantee. Gitea Actions now requires
|
||||
benchmark success before image publication and the aggregate `required` check.
|
||||
A successful live PR run, publication setup and migration-gate activation remain
|
||||
with [#268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268). No issue is
|
||||
closed by this local verification.
|
||||
|
||||
## Environment and memory
|
||||
|
||||
AMD Ryzen 5 7640U, 12 logical CPUs, approximately 16 GiB RAM; Linux
|
||||
`6.18.33.2-microsoft-standard-WSL2`. Node 24.18.0, Bun 1.3.14, Vitest 5.0.3,
|
||||
TypeScript 6.0.3, Angular core 22.2.1 and date-holidays 3.30.2. The calculation
|
||||
calendar date is fixed to `2024-01-01`, matching the benchmark test. Node runs
|
||||
are unthrottled. These versions differ from the configured CI container's
|
||||
Node 24.21.0 and Bun 1.4.2; live CI performance has not been measured here.
|
||||
|
||||
The passing protocol's sampled peak process-tree RSS is **1,528,056 KiB**,
|
||||
sampled every 500 ms across the timeout/Bun/Vitest process tree. RSS includes
|
||||
multiple processes and runtime overhead; it is not a JS-heap measurement.
|
||||
Chromium's `performance.memory` is unavailable in the Node protocol, so its
|
||||
`peak_main_thread_heap_bytes` cells remain blank.
|
||||
|
||||
## Profile and changes
|
||||
|
||||
A separate, explicitly interrupted 20-second loaded-year diagnostic used Node's
|
||||
inspector CPU and allocation sampling. Candidate finalization, state copying,
|
||||
calendar observation construction, period bounds and work-sequence scanning
|
||||
were substantial costs. Allocation sampling included collected objects; its
|
||||
cumulative allocation estimates are not peak live memory or protocol timings.
|
||||
The diagnostic retained a valid 72-placement partial plan on cancellation.
|
||||
|
||||
A separate 20-second repeated-work probe observed 29 preference-pass executions
|
||||
and no identical input-state revisit among cache misses. Complete obligation
|
||||
previews remain in place; there is no new state-equivalence or search-budget
|
||||
shortcut.
|
||||
|
||||
The change addresses those measured paths:
|
||||
|
||||
- Evaluate objectives in their actual priority order. Once a protected prefix
|
||||
loses, or an admissible candidate loses to the current best candidate, lower
|
||||
objective values cannot reverse that lexicographic comparison. Full vectors
|
||||
remain available for retained improvements, displayed explanations and date
|
||||
analysis; tied-prefix additions keep their original retention behavior.
|
||||
- Build cached calendar observations only when an evaluated objective needs
|
||||
them. The existing request-local, 4096-entry geometry cache retains its
|
||||
boundary; balance quantities, provenance and supporting status remain outside
|
||||
it. Work-sequence lengths use the prepared working-day prefixes and period
|
||||
anchors instead of rebuilding every sequence's date array for scoring.
|
||||
- Share existing placements for reading during a single-date assignment, while
|
||||
continuing to copy balances and earned activations. Bundle and relocation
|
||||
paths detach placements before changing supporting status. Snapshot,
|
||||
provenance and other mutable-copy paths retain their deep copies.
|
||||
- Reuse calendar-request validation during finalization instead of looking up
|
||||
and scanning the same provenance again for each placement.
|
||||
|
||||
No candidate destinations, allocation choices, obligation previews or rule
|
||||
priorities are removed. Search limits, earned-entitlement chronology, final
|
||||
quality criteria and deterministic tie-breaks are unchanged. No elapsed-time
|
||||
engine cutoff is introduced.
|
||||
|
||||
## Differential and diagnostic measurements
|
||||
|
||||
Complete outcomes, stable explanations and final-state snapshots are compared
|
||||
with the saved baseline. The configuration matrix contains 182 cases: supported
|
||||
conditions, aggregations and directions; personal objectives before and after
|
||||
loss prevention; typed obligations and preferences; declarations; at-risk and
|
||||
deferred balances; custom deficit thresholds; and empty/disabled rule lists.
|
||||
Three permanent regression cases also pin reordered and custom objective
|
||||
vectors, placement dates, provenance and first decisive objectives.
|
||||
|
||||
All seven completed diagnostic comparisons also match in full, including the
|
||||
loaded year and both longer horizons. Table cells show **before → after**.
|
||||
|
||||
| Scenario | Preparation (ms) | Planning work (s) | Wall through explanation (s) | Maximum timer delay (ms) | Sampled Node heap (MiB) |
|
||||
| --------------------------- | ---------------- | ----------------- | ---------------------------- | ------------------------ | ----------------------- |
|
||||
| representative year | 39.7 → 37.5 | 1.261 → 0.787 | 1.302 → 0.826 | 7.7 → 8.5 | 76.5 → 90.8 |
|
||||
| typed obligations | 10.9 → 9.4 | 8.563 → 5.642 | 8.574 → 5.652 | 7.8 → 11.2 | 127.7 → 115.1 |
|
||||
| chained earned entitlements | 5.4 → 6.7 | 0.025 → 0.026 | 0.030 → 0.033 | 0.0 → 0.0 | — → — |
|
||||
| deliberate conflict | 10.1 → 9.5 | 0.004 → 0.004 | 0.013 → 0.013 | 0.0 → 0.0 | — → — |
|
||||
| loaded year | 8.8 → 9.1 | 143.648 → 72.029 | 143.664 → 72.043 | 14.6 → 16.2 | 206.1 → 177.3 |
|
||||
| two years | 17.6 → 12.3 | 5.470 → 2.962 | 5.491 → 2.977 | 13.3 → 11.0 | 186.9 → 164.8 |
|
||||
| three years | 16.4 → 14.4 | 19.674 → 10.287 | 19.696 → 10.310 | 15.8 → 16.1 | 289.2 → 253.5 |
|
||||
|
||||
The cold diagnostics run each unchanged scenario once, without warm-up or
|
||||
coverage, through `calculatePlanningAsyncInternal`, with the same 2048 MiB heap
|
||||
and calculation date. They are separate from the acceptance protocol. Occasional
|
||||
focused/static checks ran during the baseline investigation; those diagnostic
|
||||
timings are informational, not an isolated speedup estimate. The final diagnostic
|
||||
run has no competing build/test workload.
|
||||
|
||||
Diagnostic preparation ends at the first non-preparation progress callback;
|
||||
planning work comes from the core's timing artifact; wall time ends after
|
||||
explanation delivery. In the protocol TSV, `preparation_ms` instead measures
|
||||
request cloning, while calendar preparation is included in `planning_ms`.
|
||||
|
||||
Diagnostic timer delay and `process.memoryUsage().heapUsed` are sampled every
|
||||
50 ms. Heap is the whole Node process, including earlier scenarios' live data
|
||||
and caches. A dash means no interval sample was taken before a fast scenario
|
||||
finished. These are neither allocation totals nor worker-isolated heap claims.
|
||||
|
||||
## Native browser checks
|
||||
|
||||
Production build, native Chrome 150.0.7871.186 Workers, 375×915 viewport,
|
||||
device scale 1, reduced motion and 4× CPU throttling on the laptop above.
|
||||
Each completion uses a new page and worker in one isolated Chrome process,
|
||||
without a planner warm-up; browser/resource caches are shared between pages.
|
||||
The cancellation checks use a separate isolated Chrome process. No other
|
||||
build, test or profiler runs alongside these measurements.
|
||||
|
||||
| Scenario | Outcome usable (s) | Calendar ready (s) | Full artifact delivery (s) | Maximum page timer delay (ms) | Peak page heap (MiB) | Chrome RSS before / after outcome (MiB) |
|
||||
| ------------------- | ------------------ | ------------------ | -------------------------- | ----------------------------- | -------------------- | --------------------------------------- |
|
||||
| representative year | 2.070 | 2.574 | 2.158 | 886.5 | 22.8 | 1482.5 / 1488.4 |
|
||||
| two years | 5.864 | 6.879 | 6.878 | 1287.9 | 22.8 | 1538.1 / 1540.3 |
|
||||
| three years | 15.865 | 17.864 | 17.861 | 1987.2 | 21.1 | 1503.2 / 1506.8 |
|
||||
|
||||
Every completion returns `Plan` through `dates`, with 30, 60 or 90 placements,
|
||||
its stable explanation and one planning request. Worker heartbeats continue
|
||||
through the long calculations. There are no unexpected page exceptions or
|
||||
page-level horizontal overflow at the measured viewport.
|
||||
|
||||
During the three-year worker calculation, keyboard editing changed the first
|
||||
visible balance from 20 to 99. The captured request remained at 20, the plan
|
||||
still contained 90 placements, and no second calculation was sent. Its displayed
|
||||
estimate therefore followed the pending edited configuration (22–136 seconds).
|
||||
The unchanged 90-day inputs in the cancellation runs retain their 11–68 second
|
||||
range; cancelled outcomes add no completion calibration sample. The existing
|
||||
session calibration and request-to-rendered-calendar contract are unchanged;
|
||||
these samples do not justify changing the rough initial estimate.
|
||||
|
||||
Page heap is Chromium's main-thread `performance.memory` sample every 50 ms;
|
||||
it excludes the worker heap. Chrome RSS sums the isolated browser process tree
|
||||
at 500 ms intervals and outcome/artifact boundaries, including workers,
|
||||
renderers and shared pages counted in each process's RSS. It is not a unique
|
||||
physical-memory or worker-heap measurement. The post-outcome samples extend
|
||||
through calendar readiness and artifact delivery. Calendar readiness is
|
||||
observed by a 50 ms page timer after the loading overlay disappears, rather
|
||||
than a physical paint measurement.
|
||||
|
||||
The observed page timer delays include calendar rendering and reach about
|
||||
**2.0 seconds**. Inputs accept edits during worker planning; final calendar
|
||||
rendering remains a visible main-thread pause.
|
||||
|
||||
Both cancellation flows activate Cancel with Enter and send one cancel message,
|
||||
with no additional planning request:
|
||||
|
||||
| Cancellation point | Cancel to outcome (ms) | Cancel to artifacts (ms) | Retained placements |
|
||||
| ---------------------------------------------------------- | ---------------------- | ------------------------ | ------------------- |
|
||||
| Before a valid plan, controlled startup | 61.3 | 155.0 | 0 |
|
||||
| Active optimization, after a valid 90-placement checkpoint | 20.6 | 1979.4 | 90 |
|
||||
|
||||
The preparation probe queues outgoing worker messages until the keyboard action,
|
||||
then releases the plan and cancel in order. It verifies cancellation before any
|
||||
valid plan, rather than ordinary startup-latency performance. It reports
|
||||
`PlanningIncomplete` without an incumbent or a false conflict. The active
|
||||
cancellation displays **Calculation cancelled — partial plan**, retains all 90
|
||||
placements and delivers its partial-plan explanation. The first keyboard probe
|
||||
omitted the Enter character event and issued no cancel; it was corrected and
|
||||
excluded from cancellation evidence.
|
||||
|
||||
No physical reference-phone run is available. Desktop CPU throttling does not
|
||||
establish phone performance or a duration ceiling. The browser reference
|
||||
protocol still uses three measured repetitions; the required Node protocol uses
|
||||
two. These counts are unchanged.
|
||||
|
||||
## Correctness, static checks and build
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
|
||||
bunx tsc --noEmit -p tsconfig.app.json
|
||||
bunx tsc --noEmit -p tsconfig.spec.json
|
||||
bun run lint
|
||||
bun run format:check
|
||||
bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
|
||||
node scripts/ci-policy.test.mjs
|
||||
node scripts/ci-image.test.mjs
|
||||
bun run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
All pass. Functional coverage: **633 tests across 33 files**, 93.61% statements,
|
||||
85.99% branches, 95.70% functions and 95.44% lines, in 92.05 seconds with the
|
||||
existing 2 GiB Node heap. The separate benchmark also passes, as recorded above.
|
||||
Coverage settings and thresholds are unchanged. The source changes were also
|
||||
checked with the 266-test focused planner suite during implementation.
|
||||
|
||||
A local probe runs the actual aggregate shell body for 12 combinations of
|
||||
benchmark and image outcomes. Failed, cancelled and skipped benchmarks block
|
||||
it; successful benchmarks still preserve the intentional fork/manual image
|
||||
skip. Both `images` and `required` depend on `benchmark`. The aggregate's first
|
||||
failed check exits explicitly, without depending on the runner's shell flags.
|
||||
|
||||
The production build retains the existing warning budgets: initial bundle
|
||||
2.57 MB against 2.50 MB, and calendar-year styles 12.98 kB against 8 kB. Both
|
||||
remain below their unchanged error limits. Sandbox restrictions prevented the
|
||||
first build/policy attempt; reruns with local socket/child-process permissions
|
||||
passed. Semgrep and Trivy are not installed locally, so their security jobs,
|
||||
actual image publication and a live Gitea PR run remain unverified.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Use the recorded Node/Bun versions and frozen lockfile. Run the exact acceptance
|
||||
command above from the integrated baseline checkout for the before measurement,
|
||||
and from this working result for the after measurement. The test prints the
|
||||
entire TSV report, including failures of its unchanged assertions.
|
||||
|
||||
For the separate cold diagnostics, `measure-core.mjs` runs the bundled baseline
|
||||
or optimized core over the unchanged `benchmarkScenarios()` JSON in fixture
|
||||
order. Generate that JSON in a separate process so the measured Node process
|
||||
does not include Angular fixture-export overhead:
|
||||
|
||||
```sh
|
||||
mkdir -p tmp /tmp/vac-issue-78
|
||||
cat > tmp/issue78-fixtures.ts <<'TS'
|
||||
import '@angular/compiler';
|
||||
import { benchmarkScenarios } from '../src/app/benchmark/leave-planner-benchmark';
|
||||
console.log(JSON.stringify(benchmarkScenarios()));
|
||||
TS
|
||||
bun build tmp/issue78-fixtures.ts --target=node --outfile /tmp/vac-issue-78/fixtures.mjs
|
||||
node /tmp/vac-issue-78/fixtures.mjs > /tmp/vac-issue-78/scenarios.json
|
||||
```
|
||||
|
||||
The diagnostic runner supplies `calculationDate: '2024-01-01'`, captures
|
||||
outcome/explanation/final-state callbacks, observes preparation progress and
|
||||
samples timer delay and process heap every 50 ms:
|
||||
|
||||
```sh
|
||||
bun build src/app/services/leave-planner.core.ts --target=node --outfile /tmp/vac-issue-78/optimized-core.mjs
|
||||
node --max-old-space-size=2048 /tmp/vac-issue-78/measure-core.mjs optimized
|
||||
node --max-old-space-size=2048 /tmp/vac-issue-78/compare-configurations.mjs
|
||||
```
|
||||
|
||||
The baseline bundle and complete requests, results and profiling sources are
|
||||
kept beside those runners. CPU/allocation profiling and the cancelled repeated-
|
||||
work probe are diagnostics only; neither counts as a completed benchmark run.
|
||||
|
||||
Save the following as `/tmp/vac-issue-78/measure-core.mjs` to recreate the cold
|
||||
measurements and complete comparison snapshots. Build `baseline-core.mjs` from
|
||||
the recorded baseline with the same `bun build` command, then run with
|
||||
`baseline`; use `optimized` for this implementation.
|
||||
|
||||
```js
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
const engine = process.argv[2] || 'baseline';
|
||||
const { calculatePlanningAsyncInternal } = await import(`./${engine}-core.mjs`);
|
||||
const directory = new URL(`./${engine}-measurements/`, import.meta.url);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const scenarios = JSON.parse(readFileSync(new URL('./scenarios.json', import.meta.url)));
|
||||
for (const scenario of scenarios.filter(
|
||||
(item) => !process.argv[3] || item.name === process.argv[3],
|
||||
)) {
|
||||
let artifacts;
|
||||
let outcomeAt;
|
||||
let preparationMs;
|
||||
let peakHeapUsedBytes = 0;
|
||||
let maxTimerDelayMs = 0;
|
||||
const start = performance.now();
|
||||
let lastTick = start;
|
||||
const sample = setInterval(() => {
|
||||
const tick = performance.now();
|
||||
maxTimerDelayMs = Math.max(maxTimerDelayMs, tick - lastTick - 50);
|
||||
lastTick = tick;
|
||||
peakHeapUsedBytes = Math.max(peakHeapUsedBytes, process.memoryUsage().heapUsed);
|
||||
}, 50);
|
||||
const outcome = await calculatePlanningAsyncInternal(
|
||||
{
|
||||
...scenario.request,
|
||||
calculationDate: '2024-01-01',
|
||||
progress: (stage) => {
|
||||
if (stage !== 'preparation') preparationMs ??= performance.now() - start;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(value) => {
|
||||
artifacts = value;
|
||||
},
|
||||
() => {
|
||||
outcomeAt = performance.now() - start;
|
||||
},
|
||||
);
|
||||
clearInterval(sample);
|
||||
const measurement = {
|
||||
scenario: scenario.name,
|
||||
diagnostic: true,
|
||||
warmups: 0,
|
||||
repetitions: 1,
|
||||
preparationMs,
|
||||
outcomeMs: outcomeAt,
|
||||
planningWorkMs: artifacts.timings?.planningWorkMs,
|
||||
wallMs: performance.now() - start,
|
||||
maxTimerDelayMs,
|
||||
peakHeapUsedBytes,
|
||||
peakRssKiB: process.resourceUsage().maxRSS,
|
||||
outcome: outcome.kind,
|
||||
placements: outcome.placements?.length,
|
||||
lastStage: outcome.lastCompletedStage,
|
||||
};
|
||||
writeFileSync(
|
||||
new URL(scenario.name.replaceAll(' ', '-') + '.json', directory),
|
||||
JSON.stringify({
|
||||
measurement,
|
||||
outcome,
|
||||
explanation: artifacts.explanation,
|
||||
finalState: artifacts.finalState,
|
||||
}),
|
||||
);
|
||||
console.log(JSON.stringify(measurement));
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# PR #278: build placement date keys directly
|
||||
|
||||
Measured on 2026-10-16, starting from PR #278 at `cbde23f`.
|
||||
|
||||
The unchanged benchmark finished in 299.372 seconds before this change, leaving
|
||||
only 0.628 seconds before its 300-second timeout. A separate loaded-year CPU
|
||||
profile attributed 8.27 of 66.57 seconds to `placementTieKey`, including its
|
||||
callees. That function runs when looking up cached calendar observations and
|
||||
when comparing final placement dates.
|
||||
|
||||
The one-line optimization sorts the placement map's date keys directly, using
|
||||
native string sorting. It removes the intermediate placement-to-date array and
|
||||
the comparison callback. Every placement map entry is keyed by its placement's
|
||||
date, and native string sorting preserves the existing lexical date order, so
|
||||
the resulting keys are identical. Search choices and scoring are unchanged.
|
||||
|
||||
## Before and after
|
||||
|
||||
Both runs used Node 24.18.0, Bun 1.3.14, Vitest 5.0.3, the same local machine,
|
||||
and the exact command below. No other test, build or profiling workload ran
|
||||
alongside either measurement.
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||
```
|
||||
|
||||
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
|
||||
fixtures and assertions remain unchanged. Coverage is disabled for this command.
|
||||
|
||||
| Measurement | Before | After |
|
||||
| ---------------------------------------------- | --------: | --------: |
|
||||
| Entire command, external wall time | 299.372 s | 268.573 s |
|
||||
| Benchmark test | 293.620 s | 263.265 s |
|
||||
| Loaded year, mean of two measured calculations | 76.649 s | 67.747 s |
|
||||
| Headroom before timeout | 0.628 s | 31.427 s |
|
||||
|
||||
The entire command was 10.3% faster in this before-and-after pair. Both runs
|
||||
completed successfully, and every repeated scenario remained deterministic.
|
||||
These are local measurements; Gitea runner performance still needs confirmation.
|
||||
|
||||
## Correctness checks
|
||||
|
||||
Complete outcomes, stable explanations and final-state snapshots match the
|
||||
baseline for all seven scenarios. These separate comparisons ran alongside
|
||||
functional checks; their diagnostic timings are not used in the table above.
|
||||
|
||||
The functional coverage suite passes all 635 tests across 33 files. Application
|
||||
and spec type checks, lint, source formatting and `git diff --check` also pass.
|
||||
No browser timing run or live CI rerun was performed for this change.
|
||||
|
||||
The profiling runner and full protocol logs are retained in
|
||||
`/tmp/vac-opt278-measure`. The original diagnostic reproduction is documented in
|
||||
[issue-78.md](issue-78.md#reproduction).
|
||||
@@ -0,0 +1,61 @@
|
||||
# PR #278: reuse counts for immutable calendar dates
|
||||
|
||||
Measured on 2026-10-16 against `c7e7abc`, which already includes the
|
||||
[direct date-key optimization](pr-278-date-keys.md).
|
||||
|
||||
A loaded-year CPU profile attributed 4.06 of 69.37 seconds to repeatedly counting
|
||||
distinct non-working dates for time-off-period objectives. The existing calendar
|
||||
cache already shares frozen date arrays between candidate plans, but each score
|
||||
rebuilt a `Set` to count the same dates again.
|
||||
|
||||
The objective evaluator now keeps those counts in a `WeakMap` keyed by the frozen
|
||||
array. Mutable arrays are recounted on every evaluation. The weak keys allow
|
||||
unused arrays to be collected, and configuration validation and duplicate-date
|
||||
handling remain intact. This change touches only that scoring operation.
|
||||
|
||||
## Before and after
|
||||
|
||||
Both protocol runs used Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the same local
|
||||
machine. The baseline is the completed run recorded for `c7e7abc`; its saved core
|
||||
bundle is byte-for-byte identical to a fresh bundle of that commit. No other
|
||||
test, build or profiling workload ran alongside either protocol measurement.
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||
```
|
||||
|
||||
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
|
||||
fixtures, assertions and resource limits remain unchanged.
|
||||
|
||||
| Measurement | Before | After |
|
||||
| ---------------------------------------------- | --------: | --------: |
|
||||
| Entire command, external wall time | 268.573 s | 256.034 s |
|
||||
| Benchmark test | 263.265 s | 250.652 s |
|
||||
| Loaded year, mean of two measured calculations | 67.747 s | 64.360 s |
|
||||
| Headroom before timeout | 31.427 s | 43.966 s |
|
||||
|
||||
The full command was 4.7% faster in this pair. Both runs completed successfully,
|
||||
and every repeated scenario remained deterministic. Live CI performance still
|
||||
needs confirmation.
|
||||
|
||||
An isolated check using the loaded-year plan's frozen date arrays took
|
||||
551–554 ms before and 46–57 ms after for 100,000 scoring calls, following 5,000
|
||||
warm-ups per run in before/after/after/before order. That result isolates the repeated
|
||||
counting cost; the protocol above measures its effect on complete calculations.
|
||||
|
||||
## Correctness checks
|
||||
|
||||
Complete outcomes, stable explanations and final-state snapshots match `c7e7abc`
|
||||
for all seven scenarios. These separate comparisons ran alongside functional
|
||||
checks; their diagnostic timings are not used in the protocol comparison.
|
||||
|
||||
The functional coverage suite passes all 638 tests across 33 files. Three new
|
||||
regression cases cover duplicate dates and changes to mutable arrays alongside
|
||||
frozen arrays for sum, count and maximum scores. Application and spec type checks,
|
||||
lint, formatting and `git diff --check` also pass.
|
||||
|
||||
No browser timing run or live CI rerun was performed for this change.
|
||||
|
||||
Diagnostic sources, profiles, bundles and full logs are retained in
|
||||
`/tmp/vac-opt278-step2`. The original core diagnostic runner is documented in
|
||||
[issue-78.md](issue-78.md#reproduction).
|
||||
@@ -0,0 +1,65 @@
|
||||
# PR #278: reject impossible mandatory moves before copying
|
||||
|
||||
Measured on 2026-10-16 against `433a8b0`, which includes the
|
||||
[immutable date-count optimization](pr-278-immutable-date-counts.md).
|
||||
|
||||
A loaded-year CPU profile attributed 5.09 seconds to candidate assignment and
|
||||
6.53 seconds to final validation out of 58.24 seconds. Generic moves and swaps
|
||||
copied the candidate state before discovering that a mandatory placement's new
|
||||
date or leave type could never satisfy its rule.
|
||||
|
||||
The shared assignment function now calls the existing `mandatoryRelocationDate`
|
||||
check before copying. It checks only necessary date and type conditions. Complete
|
||||
multi-day patterns and earned-entitlement chronology are still validated after
|
||||
the candidate is assembled. Preferences remain movable, and valid alternatives,
|
||||
objective order and deterministic tie breaks remain unchanged.
|
||||
|
||||
## Before and after
|
||||
|
||||
Both protocol runs use Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the same local
|
||||
machine. This comparison uses a fresh baseline from this session. No other test,
|
||||
build or profiling workload runs alongside either protocol measurement.
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||
```
|
||||
|
||||
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
|
||||
fixtures, assertions and resource limits remain unchanged.
|
||||
|
||||
| Measurement | Before | After |
|
||||
| ---------------------------------------------------- | --------: | --------: |
|
||||
| Vitest duration | 232.630 s | 228.130 s |
|
||||
| Benchmark test | 231.483 s | 227.011 s |
|
||||
| Loaded year, mean of two measured calculations | 59.255 s | 57.850 s |
|
||||
| Typed obligations, mean of two measured calculations | 4.552 s | 4.368 s |
|
||||
| Headroom before timeout (Vitest duration) | 67.370 s | 71.870 s |
|
||||
|
||||
The Vitest duration was 1.9% shorter in this pair. External wall time was not
|
||||
recorded for these runs. Both runs completed successfully, and every repeated
|
||||
scenario remained deterministic. The baseline is faster than the one recorded
|
||||
for the preceding optimization, so only same-session pairs are comparable.
|
||||
Live CI performance still needs confirmation.
|
||||
|
||||
## Correctness checks
|
||||
|
||||
Complete outcomes, stable explanations and final-state snapshots match the
|
||||
baseline for all seven scenarios. Baseline snapshots come from the preceding
|
||||
optimization's saved run, whose bundle matches a fresh build of `433a8b0` byte for
|
||||
byte; the loaded-year snapshot was also regenerated in this session. Diagnostic
|
||||
comparisons ran alongside functional checks, so their timings are not used for
|
||||
the protocol comparison.
|
||||
|
||||
The functional coverage suite passes all 640 tests across 33 files. Two additional
|
||||
regression cases cover obligations and lower-priority preferences when a calendar
|
||||
objective competes for the same leave balance. Both cases pass before and after
|
||||
the optimization. Existing tests also exercise typed reassignments, complete
|
||||
multi-day patterns, earned entitlements and counterfactual explanations.
|
||||
Application and spec type checks, lint, source formatting and `git diff --check`
|
||||
pass.
|
||||
|
||||
No browser timing run or live CI rerun was performed for this change.
|
||||
|
||||
Diagnostic sources, profiles, bundles and full logs are retained in
|
||||
`/tmp/vac-opt278-step3`. The original core diagnostic runner is documented in
|
||||
[issue-78.md](issue-78.md#reproduction).
|
||||
+18
-10
@@ -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` | Runs outside coverage with the same worker/heap limits and 300-second protocol timeout. Advisory while #78 is open. |
|
||||
| `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, 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. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. Excludes the advisory benchmark. |
|
||||
| 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
|
||||
|
||||
+4
-3
@@ -38,7 +38,7 @@
|
||||
"@angular/platform-browser-dynamic": "22.2.1",
|
||||
"@angular/platform-server": "22.2.1",
|
||||
"@angular/router": "22.2.1",
|
||||
"@angular/ssr": "22.2.2",
|
||||
"@angular/ssr": "22.2.3",
|
||||
"date-holidays": "^3.30.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"express": "^5.2.1",
|
||||
@@ -49,8 +49,8 @@
|
||||
"@analogjs/platform": "^2.7.5",
|
||||
"@analogjs/vite-plugin-angular": "^2.7.5",
|
||||
"@analogjs/vitest-angular": "^2.7.5",
|
||||
"@angular/build": "22.2.2",
|
||||
"@angular/cli": "22.2.2",
|
||||
"@angular/build": "22.2.3",
|
||||
"@angular/cli": "22.2.3",
|
||||
"@angular/compiler-cli": "22.2.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nx/devkit": "^23.0.0",
|
||||
@@ -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,12 +119,20 @@ describe('leave planner benchmark protocol', () => {
|
||||
loaded[0].preparationMs + loaded[0].planningMs,
|
||||
);
|
||||
const report = benchmarkReport(measurements);
|
||||
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);
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
@@ -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
@@ -334,6 +334,43 @@ describe('composed calendar objectives', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['obligation', 'preference'] as const)(
|
||||
'keeps a lower %s only when required while completing a holiday week',
|
||||
(force) => {
|
||||
const { plan } = calculate({
|
||||
...concurrent,
|
||||
rules: [
|
||||
holiday,
|
||||
{
|
||||
id: 'monday',
|
||||
type: 'time_window',
|
||||
force,
|
||||
startDate: '2024-04-29',
|
||||
endDate: '2024-04-29',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (force === 'obligation') {
|
||||
expect(plan.placements).toMatchObject([
|
||||
{ date: '2024-04-29', type: 'cp', ruleIds: ['monday'] },
|
||||
]);
|
||||
expect(plan.metrics.holidayWeekValue).toBe(0);
|
||||
} else {
|
||||
expect(plan.placements.map(({ date }) => date)).toEqual([
|
||||
'2024-05-06',
|
||||
'2024-05-07',
|
||||
'2024-05-10',
|
||||
]);
|
||||
expect(plan.metrics.holidayWeekValue).toBe(5);
|
||||
expect(plan.placements.every(({ ruleIds }) => !ruleIds.includes('monday'))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('adds no residual loss-prevention or calendar placements when their rules are disabled', () => {
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-04-29', end: '2024-05-04' },
|
||||
|
||||
@@ -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,22 +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 = 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[];
|
||||
@@ -588,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.
|
||||
@@ -636,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,10 +700,10 @@ function reason(code: string, message: string, ruleIds?: string[]): PlanningReas
|
||||
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
|
||||
}
|
||||
|
||||
function cloneState(state: State): State {
|
||||
return {
|
||||
balances: state.balances.map((balance) => ({ ...balance })),
|
||||
placements: new Map(
|
||||
function cloneState(state: State, placements?: State['placements']): State {
|
||||
const clonedPlacements =
|
||||
placements ??
|
||||
new Map(
|
||||
[...state.placements.entries()].map(([date, placement]) => [
|
||||
date,
|
||||
{
|
||||
@@ -731,7 +711,11 @@ function cloneState(state: State): State {
|
||||
ruleIds: new Set(placement.ruleIds),
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
derivePlacements(state.placements, clonedPlacements);
|
||||
return {
|
||||
balances: state.balances.map((balance) => ({ ...balance })),
|
||||
placements: clonedPlacements,
|
||||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||||
...activation,
|
||||
dates: [...activation.dates],
|
||||
@@ -1481,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,
|
||||
@@ -1513,9 +1498,13 @@ function assignDateToBalance(
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const result = cloneState(state);
|
||||
// Reject impossible mandatory moves before copying; validate complete patterns later.
|
||||
if (!mandatoryRelocationDate(date, balance, ruleIds, context)) return null;
|
||||
// Existing placements stay read-only; this assignment owns only its new placement.
|
||||
const result = cloneState(state, new Map(state.placements));
|
||||
const target = result.balances.find((item) => item.id === balanceId)!;
|
||||
target.remaining--;
|
||||
notePlacementAdded(state.placements, result.placements, date);
|
||||
result.placements.set(date, {
|
||||
date,
|
||||
balanceId,
|
||||
@@ -1856,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;
|
||||
@@ -2105,8 +2095,10 @@ function finalizeRuleCandidate(state: State, context: SearchContext, before: Sta
|
||||
const patternCredits = new Map<string, Set<string>>();
|
||||
for (const placement of state.placements.values()) {
|
||||
let lostCalendarRequest = false;
|
||||
let hasCalendarRequest = false;
|
||||
for (const id of placement.ruleIds) {
|
||||
const rule = context.rules.find((item) => item.id === id);
|
||||
if (rule?.type === 'optimization') continue;
|
||||
const valid = counterfactualRuleValid(
|
||||
placement.date,
|
||||
placement.type,
|
||||
@@ -2135,18 +2127,16 @@ function finalizeRuleCandidate(state: State, context: SearchContext, before: Sta
|
||||
withinRequest = patternCredits.get(id)!.has(placement.date);
|
||||
}
|
||||
if (valid && withinRequest) {
|
||||
hasCalendarRequest = true;
|
||||
if (rule?.type === 'time_window') windowCredits.set(id, credited + 1);
|
||||
continue;
|
||||
}
|
||||
if (!valid && rule?.force === 'obligation') return null;
|
||||
if (result === state) result = cloneState(state);
|
||||
result.placements.get(placement.date)!.ruleIds.delete(id);
|
||||
lostCalendarRequest ||= rule?.type !== 'optimization';
|
||||
lostCalendarRequest = true;
|
||||
}
|
||||
const current = result.placements.get(placement.date)!;
|
||||
const hasCalendarRequest = [...current.ruleIds].some((id) =>
|
||||
context.rules.some((rule) => rule.id === id && rule.type !== 'optimization'),
|
||||
);
|
||||
const previous = before.placements.get(placement.date);
|
||||
let supporting = current.supporting;
|
||||
if (hasCalendarRequest) supporting = false;
|
||||
@@ -2272,7 +2262,8 @@ function* calendarPlacementCandidates(
|
||||
if (index === missing.length) {
|
||||
const benefit = supportingCalendarBenefit(before, current, context);
|
||||
for (const date of additions) {
|
||||
const placement = current.placements.get(date)!;
|
||||
const placement = { ...current.placements.get(date)! };
|
||||
current.placements.set(date, placement);
|
||||
const balance = current.balances.find((item) => item.id === placement.balanceId)!;
|
||||
placement.supporting = benefit && !isAtRisk(balance, context);
|
||||
}
|
||||
@@ -2359,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,
|
||||
@@ -2367,18 +2423,42 @@ function evaluateRuleCandidate(
|
||||
addition = false,
|
||||
baseline = activeObjectiveVector(before, context, throughIndex),
|
||||
incumbentVector = objectiveVector(before, context),
|
||||
): { state: State; vector: PlanningObjectiveVector } | undefined {
|
||||
): { 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 = activeObjectiveVector(state, context);
|
||||
const comparison = compareObjectiveLists(active.slice(0, throughIndex + 1), baseline);
|
||||
const active: PlanningObjective[] = [];
|
||||
let comparison = 0;
|
||||
let incumbentComparison = 0;
|
||||
for (const objective of activeObjectives(state, context)) {
|
||||
const index = active.length;
|
||||
active.push(objective);
|
||||
if (index <= throughIndex && comparison === 0) {
|
||||
comparison = compareObjectiveValues(
|
||||
objective.direction,
|
||||
objective.value,
|
||||
baseline[index].value,
|
||||
);
|
||||
// A worse protected prefix cannot improve the jointly valid incumbent.
|
||||
if (comparison > 0) return undefined;
|
||||
}
|
||||
if (incumbentComparison === 0)
|
||||
incumbentComparison = compareObjectiveValues(
|
||||
objective.direction,
|
||||
objective.value,
|
||||
incumbentVector.objectives[index + 1].value,
|
||||
);
|
||||
if (incumbentComparison > 0 && (comparison < 0 || index >= throughIndex))
|
||||
return addition && comparison === 0 ? undefined : { state };
|
||||
}
|
||||
const vector = objectiveVector(state, context, active);
|
||||
// Preserve the best valid candidate even if the active prefix rejects it.
|
||||
if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0)
|
||||
context.retain?.(state, undefined, vector);
|
||||
// shortcut: single additions cannot cross equal-value plateaus; add coordinated balance bundles when a measured configuration needs them.
|
||||
if (comparison > 0 || (addition && comparison === 0)) return undefined;
|
||||
if (addition && comparison === 0) return undefined;
|
||||
return { state, vector };
|
||||
}
|
||||
|
||||
@@ -2622,6 +2702,7 @@ function* optimizeRulePlacements(
|
||||
bestVector,
|
||||
);
|
||||
if (!evaluated) return false;
|
||||
if (!evaluated.vector) return true;
|
||||
const compared = compareObjectiveLists(
|
||||
evaluated.vector.objectives,
|
||||
bestVector.objectives,
|
||||
@@ -2805,7 +2886,7 @@ function* optimizeRulePlacements(
|
||||
moved &&
|
||||
dateInvariantObjectives &&
|
||||
compareObjectiveLists(
|
||||
tieBreakObjectives(moved),
|
||||
tieBreakObjectives(moved, context),
|
||||
beforeVector.objectives.slice(-2),
|
||||
) >= 0
|
||||
) {
|
||||
@@ -2842,8 +2923,14 @@ function* optimizeRulePlacements(
|
||||
(candidate) => {
|
||||
if (rule?.type === 'optimization' && !isAtRisk(balance, context)) {
|
||||
beforeCalendar ??= optimizationObservations(result, context);
|
||||
candidate.placements.get(date)!.supporting =
|
||||
supportingCalendarBenefit(beforeCalendar, candidate, context);
|
||||
candidate.placements.set(date, {
|
||||
...candidate.placements.get(date)!,
|
||||
supporting: supportingCalendarBenefit(
|
||||
beforeCalendar,
|
||||
candidate,
|
||||
context,
|
||||
),
|
||||
});
|
||||
}
|
||||
return consider(candidate, true);
|
||||
},
|
||||
@@ -3284,71 +3371,57 @@ function preferenceStateKey(state: State): string {
|
||||
]);
|
||||
}
|
||||
|
||||
function placementTieKey(state: State): string {
|
||||
return [...state.placements.values()]
|
||||
.map((placement) => placement.date)
|
||||
.sort(compareDate)
|
||||
.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,
|
||||
throughIndex = Number.POSITIVE_INFINITY,
|
||||
): PlanningObjective[] {
|
||||
const preferences = preferenceResultsForState(state, context);
|
||||
let observations: OptimizationObservations | undefined;
|
||||
return context.rules
|
||||
.filter((rule) => rule.enabled && rule.force === 'preference')
|
||||
.slice(0, throughIndex + 1)
|
||||
.map((rule) => {
|
||||
if (rule.type === 'optimization') {
|
||||
observations ??= context.rules.some(
|
||||
(item) =>
|
||||
item.type === 'optimization' &&
|
||||
item.configuration.elements !== 'leave-balances',
|
||||
)
|
||||
? optimizationObservations(state, context)
|
||||
: {
|
||||
return [...activeObjectives(state, context, throughIndex)];
|
||||
}
|
||||
|
||||
function* activeObjectives(
|
||||
state: State,
|
||||
context: SearchContext,
|
||||
throughIndex = Number.POSITIVE_INFINITY,
|
||||
): Generator<PlanningObjective> {
|
||||
let preferences: PreferenceResult[] | undefined;
|
||||
let balances: OptimizationObservations | undefined;
|
||||
let calendar: OptimizationObservations | undefined;
|
||||
let index = 0;
|
||||
for (const rule of context.rules) {
|
||||
if (!rule.enabled || rule.force !== 'preference') continue;
|
||||
if (index++ > throughIndex) return;
|
||||
if (rule.type === 'optimization') {
|
||||
const observations =
|
||||
rule.configuration.elements === 'leave-balances'
|
||||
? (balances ??= {
|
||||
leaveBalances: optimizationBalanceObservations(state, context),
|
||||
publicHolidayWeeks: [],
|
||||
timeOffPeriods: [],
|
||||
workSequences: [],
|
||||
leavePlacements: [],
|
||||
};
|
||||
return {
|
||||
id: `optimization:${rule.id}` as const,
|
||||
direction: rule.configuration!.direction,
|
||||
value: evaluateStateOptimization(
|
||||
rule.configuration!,
|
||||
state,
|
||||
context,
|
||||
observations,
|
||||
),
|
||||
label: rule.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: `preference:${rule.id}` as const,
|
||||
direction: 'maximize' as const,
|
||||
})
|
||||
: (calendar ??= optimizationObservations(state, context));
|
||||
yield {
|
||||
id: `optimization:${rule.id}`,
|
||||
direction: rule.configuration.direction,
|
||||
value: evaluateStateOptimization(rule.configuration, state, context, observations),
|
||||
label: rule.name,
|
||||
};
|
||||
} else {
|
||||
preferences ??= preferenceResultsForState(state, context);
|
||||
yield {
|
||||
id: `preference:${rule.id}`,
|
||||
direction: 'maximize',
|
||||
value:
|
||||
preferences.find((preference) => preference.ruleId === rule.id)?.satisfied ?? 0,
|
||||
label: rule.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tieBreakObjectives(state: State): PlanningObjective[] {
|
||||
function tieBreakObjectives(state: State, context: SearchContext): PlanningObjective[] {
|
||||
return [
|
||||
{
|
||||
id: 'balance-expiry',
|
||||
@@ -3359,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',
|
||||
},
|
||||
];
|
||||
@@ -3369,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. */
|
||||
@@ -3384,210 +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);
|
||||
}
|
||||
let calendar = byDates.get(key);
|
||||
if (calendar) byDates.delete(key);
|
||||
else {
|
||||
calendar = Object.freeze({
|
||||
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),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
timeOffPeriods: Object.freeze(
|
||||
periods
|
||||
? periods.map((period) =>
|
||||
Object.freeze({
|
||||
nonWorkingDates: Object.freeze([...period.nonWorkingDates]),
|
||||
}),
|
||||
)
|
||||
: periodBounds(state, context).map(({ startIndex, endIndex }) =>
|
||||
Object.freeze({
|
||||
nonWorkingDates: calendarPeriod(startIndex, endIndex, context)
|
||||
.nonWorkingDates,
|
||||
}),
|
||||
),
|
||||
),
|
||||
workSequences: Object.freeze(
|
||||
workSequenceDates(state, context).map(({ dates, internal }) =>
|
||||
Object.freeze({ length: dates.length, internal }),
|
||||
),
|
||||
),
|
||||
});
|
||||
if (byDates.size >= 4096) byDates.delete(byDates.keys().next().value!);
|
||||
}
|
||||
byDates.set(key, calendar);
|
||||
return {
|
||||
leaveBalances: optimizationBalanceObservations(state, context),
|
||||
...calendar,
|
||||
leavePlacements: [...state.placements.values()],
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -3759,7 +3631,7 @@ function objectiveVector(
|
||||
label: 'Unsatisfied obligations',
|
||||
},
|
||||
...active,
|
||||
...tieBreakObjectives(state),
|
||||
...tieBreakObjectives(state, context),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -3898,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,
|
||||
@@ -5181,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;
|
||||
|
||||
@@ -71,6 +71,64 @@ describe('integrated optimization and earned leave', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'holiday value first',
|
||||
rules: [presets[1], presets[0], ...presets.slice(2)],
|
||||
dates: ['2024-05-06', '2024-05-07', '2024-05-10'],
|
||||
values: [0, 5, 0, 6, 5, 0, 0],
|
||||
decisive: 'optimization:default-holiday-week-value',
|
||||
},
|
||||
{
|
||||
name: 'non-working-day gain first',
|
||||
rules: [presets[2], presets[0], presets[1], ...presets.slice(3)],
|
||||
dates: ['2024-05-03', '2024-05-10', '2024-05-17'],
|
||||
values: [0, 9, 0, 0, 4, 4, 0],
|
||||
decisive: 'optimization:default-non-working-day-gain',
|
||||
},
|
||||
{
|
||||
name: 'maximum holiday value after loss prevention',
|
||||
rules: [
|
||||
presets[0],
|
||||
{
|
||||
...presets[1],
|
||||
configuration: {
|
||||
...presets[1].configuration,
|
||||
aggregation: 'maximum' as const,
|
||||
},
|
||||
},
|
||||
...presets.slice(2),
|
||||
],
|
||||
dates: ['2024-05-06', '2024-05-07', '2024-05-10'],
|
||||
values: [0, 0, 5, 6, 5, 0, 0],
|
||||
decisive: 'optimization:default-unplaced-at-risk',
|
||||
},
|
||||
])('preserves complete vectors and placement explanations with $name', (fixture) => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
...composedRequest,
|
||||
horizon: { start: '2024-04-29', end: '2024-05-18' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-05-17' }],
|
||||
rules: fixture.rules,
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan' || artifacts.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected a complete plan and explanation.');
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(fixture.dates);
|
||||
for (const placement of artifacts.explanation.placements) {
|
||||
expect(placement.objectiveVector.objectives.map(({ value }) => value)).toEqual([
|
||||
...fixture.values,
|
||||
'2024-05-17|2024-05-17|2024-05-17',
|
||||
fixture.dates.join('|'),
|
||||
]);
|
||||
expect(placement.firstDecisiveObjective).toBe(fixture.decisive);
|
||||
expect(placement.ruleIds).toEqual([fixture.decisive.slice('optimization:'.length)]);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the first equivalent funder while evaluating typed window alternatives', () => {
|
||||
const outcome = calculatePlanningInternal({
|
||||
...composedRequest,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -75,6 +75,29 @@ describe('composable planning objectives', () => {
|
||||
).toEqual([3, 7, 4, 6, 5, 2]);
|
||||
});
|
||||
|
||||
it.each(['sum', 'count', 'maximum'] as const)(
|
||||
'keeps %s distinct-date scores current with frozen and mutable period dates',
|
||||
(aggregation) => {
|
||||
const mutableDates = ['2024-05-08'];
|
||||
const observations: OptimizationObservations = {
|
||||
...empty,
|
||||
timeOffPeriods: Object.freeze([
|
||||
Object.freeze({
|
||||
nonWorkingDates: Object.freeze(['2024-01-06', '2024-01-07', '2024-01-07']),
|
||||
}),
|
||||
Object.freeze({ nonWorkingDates: mutableDates }),
|
||||
]),
|
||||
};
|
||||
const configuration = { ...OPTIMIZATION_PRESETS.nonWorkingDayGain, aggregation };
|
||||
const initial = { sum: 3, count: 2, maximum: 2 };
|
||||
expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]);
|
||||
expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]);
|
||||
mutableDates.push('2024-05-09', '2024-05-09', '2024-05-11');
|
||||
const updated = { sum: 5, count: 2, maximum: 3 };
|
||||
expect(evaluateOptimization(configuration, observations)).toBe(updated[aggregation]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['sum', 'count', 'maximum'] as const)(
|
||||
'defines empty %s as zero for every measure',
|
||||
(aggregation) => {
|
||||
|
||||
@@ -203,6 +203,9 @@ export interface OptimizationObservations {
|
||||
leavePlacements: readonly { supporting: boolean }[];
|
||||
}
|
||||
|
||||
// Calendar geometry reuses frozen date arrays across candidate plans.
|
||||
const nonWorkingDateCounts = new WeakMap<readonly string[], number>();
|
||||
|
||||
export function evaluateOptimization(
|
||||
configuration: OptimizationConfiguration,
|
||||
observations: OptimizationObservations,
|
||||
@@ -224,9 +227,14 @@ export function evaluateOptimization(
|
||||
.map((week) => week.value);
|
||||
break;
|
||||
case 'time-off-periods':
|
||||
values = observations.timeOffPeriods.map(
|
||||
(period) => new Set(period.nonWorkingDates).size,
|
||||
);
|
||||
values = observations.timeOffPeriods.map(({ nonWorkingDates }) => {
|
||||
const cached = nonWorkingDateCounts.get(nonWorkingDates);
|
||||
if (cached !== undefined) return cached;
|
||||
const count = new Set(nonWorkingDates).size;
|
||||
if (Object.isFrozen(nonWorkingDates))
|
||||
nonWorkingDateCounts.set(nonWorkingDates, count);
|
||||
return count;
|
||||
});
|
||||
break;
|
||||
case 'work-sequences':
|
||||
values = observations.workSequences
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user