github-actions[bot] commited on
Commit
ea74628
·
0 Parent(s):

chore: sync uc-hospital Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .gitignore +27 -0
  3. .pre-commit-config.yaml +21 -0
  4. AGENTS.md +112 -0
  5. CHANGELOG.md +69 -0
  6. Cargo.lock +1628 -0
  7. Cargo.toml +31 -0
  8. Dockerfile +40 -0
  9. Makefile +314 -0
  10. README.md +212 -0
  11. WIREFRAME.md +300 -0
  12. docs/api-and-solver-policy.md +90 -0
  13. docs/screenshot.png +3 -0
  14. solver.toml +43 -0
  15. solverforge.app.toml +83 -0
  16. src/api/dto.rs +259 -0
  17. src/api/dto/tests.rs +146 -0
  18. src/api/mod.rs +13 -0
  19. src/api/routes.rs +225 -0
  20. src/api/routes/tests.rs +239 -0
  21. src/api/sse.rs +74 -0
  22. src/constraints/assigned_shift.rs +14 -0
  23. src/constraints/balance_assignments.rs +12 -0
  24. src/constraints/desired_day.rs +33 -0
  25. src/constraints/minimum_rest.rs +38 -0
  26. src/constraints/mod.rs +43 -0
  27. src/constraints/one_shift_per_day.rs +21 -0
  28. src/constraints/overlapping_shift.rs +28 -0
  29. src/constraints/required_skill.rs +24 -0
  30. src/constraints/unavailable_employee.rs +58 -0
  31. src/constraints/undesired_day.rs +33 -0
  32. src/data/data_seed.rs +28 -0
  33. src/data/data_seed/availability.rs +72 -0
  34. src/data/data_seed/cohorts.rs +198 -0
  35. src/data/data_seed/coverage.rs +47 -0
  36. src/data/data_seed/demand.rs +273 -0
  37. src/data/data_seed/employees.rs +150 -0
  38. src/data/data_seed/entrypoints.rs +52 -0
  39. src/data/data_seed/large.rs +59 -0
  40. src/data/data_seed/preferences.rs +190 -0
  41. src/data/data_seed/preferences/exchange.rs +102 -0
  42. src/data/data_seed/preferences/floor.rs +76 -0
  43. src/data/data_seed/preferences/support.rs +103 -0
  44. src/data/data_seed/preferences/top_up.rs +115 -0
  45. src/data/data_seed/shifts.rs +54 -0
  46. src/data/data_seed/skills.rs +42 -0
  47. src/data/data_seed/solve_tests.rs +75 -0
  48. src/data/data_seed/tests.rs +284 -0
  49. src/data/data_seed/time_utils.rs +119 -0
  50. src/data/data_seed/validation.rs +32 -0
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ docs/screenshot.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Cargo
2
+ # will have compiled files and executables
3
+ debug
4
+ target
5
+
6
+ # These are backup files generated by rustfmt
7
+ **/*.rs.bk
8
+
9
+ # MSVC Windows builds of rustc generate these, which store debugging information
10
+ *.pdb
11
+
12
+ # Generated by cargo mutants
13
+ # Contains mutation testing data
14
+ **/mutants.out*/
15
+ test-results/
16
+ playwright-report/
17
+
18
+ # RustRover
19
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
20
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
21
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
22
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
23
+ #.idea/
24
+ PRD.md
25
+
26
+ # OSM routing cache
27
+ .osm_cache/
.pre-commit-config.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v4.5.0
4
+ hooks:
5
+ - id: check-yaml
6
+ - id: end-of-file-fixer
7
+ - id: trailing-whitespace
8
+ - id: check-merge-conflict
9
+ - id: check-added-large-files
10
+
11
+ - repo: https://github.com/gitleaks/gitleaks
12
+ rev: v8.18.0
13
+ hooks:
14
+ - id: gitleaks
15
+ - repo: https://github.com/doublify/pre-commit-rust
16
+ rev: v1.0
17
+ hooks:
18
+ - id: fmt
19
+ args: ["--", "--check"]
20
+ - id: clippy
21
+ args: ["--", "-D", "warnings"]
AGENTS.md ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Repository Guidelines
2
+
3
+ ## Project Structure & Module Organization
4
+
5
+ `src/` follows the current `solverforge-cli` app shape: `api/` for HTTP
6
+ routes, SSE, and DTOs; `solver/` for retained-job orchestration; `domain/mod.rs`
7
+ for the current `solverforge::planning_model!` manifest; `domain/` for the
8
+ exported model modules; `constraints/mod.rs` for constraint assembly; and
9
+ `constraints/*.rs` for the individual score rules. `data/mod.rs` is the stable
10
+ wrapper; `data/data_seed.rs` is the thin public data surface; and
11
+ `data/data_seed/` holds the deterministic sample dataset modules, including the
12
+ public entrypoints and the `LARGE` instance builder. `domain/plan.rs` owns
13
+ `Plan`, the scalar-variable `Shift`, and the scalar nearby hook functions;
14
+ `domain/employee.rs` and `domain/care_hub.rs` hold supporting domain types.
15
+ `static/` holds the browser app (`static/app/**/*.mjs`) and generated UI config.
16
+ Frontend tests live in `tests/frontend/`. Container packaging is defined by
17
+ `Dockerfile`.
18
+
19
+ This project depends on the published `solverforge` and `solverforge-ui` crates.
20
+
21
+ ## Build, Test, and Development Commands
22
+
23
+ - `make help` — show the supported local development and validation commands.
24
+ - `make run-release` — run the app locally on `:7860`.
25
+ - `make test` — run the standard Rust, frontend, and Playwright validation surface.
26
+ - `make test-e2e` — run the real browser Playwright smoke.
27
+ - `make ci-local` — run the Space-oriented local CI pipeline: fmt, clippy,
28
+ release build, standard tests, and Docker image build.
29
+ - `make test-slow` — run the ignored large-demo acceptance solve.
30
+ - `make pre-release` — run `make ci-local` plus the slow acceptance solve.
31
+ - `make space-build` — build the Docker image used by the Docker-based Space
32
+ deployment path.
33
+ - `cargo run --release --bin solverforge-hospital` — run the app locally on
34
+ `:7860`.
35
+ - `cargo test` — run Rust unit and integration tests.
36
+ - `cargo test large_demo_solves_to_feasible_terminal_state -- --ignored --nocapture`
37
+ — slow end-to-end solver acceptance test.
38
+ - `find static/app -name '*.mjs' -print0 | xargs -0 -n1 node --check` —
39
+ syntax-check frontend modules.
40
+ - `node --test tests/frontend/*.test.js` — run browserless frontend tests.
41
+ - `docker build -f Dockerfile -t solverforge-hospital .` — build the image from
42
+ the repository root context.
43
+
44
+ ## Coding Style & Naming Conventions
45
+
46
+ Use Rust 2021 style with `cargo fmt`; keep imports and formatting
47
+ rustfmt-compatible. Prefer small, explicit functions over clever indirection.
48
+ Rust module and file names are `snake_case`; types are `UpperCamelCase`; tests
49
+ should describe behavior plainly. Frontend modules are plain ES modules in
50
+ `snake-case` filenames. Keep generator logic deterministic: do not introduce
51
+ random behavior without a fixed seed and an explicit reason.
52
+
53
+ ## Documentation And Commenting Policy
54
+
55
+ Assume a beginner reader who is new to SolverForge and new to optimization
56
+ modeling.
57
+
58
+ - Treat `README.md`, `WIREFRAME.md`, this file,
59
+ `docs/api-and-solver-policy.md`, `docs/screenshot.png`,
60
+ `solver.toml` comments, and the visible API help in
61
+ `static/app/shell/api-guide.mjs` as one canonical documentation surface. When
62
+ one changes, audit the others that describe the same behavior.
63
+ - Add module-level docs or comments for every new module that explain its role
64
+ in the app and where it sits in the data flow.
65
+ - Add function comments when the function does real coordination work, rebuilds
66
+ invariants, shapes demo data, converts between layers, or otherwise does
67
+ something a beginner would not infer immediately from the signature.
68
+ - Write comments that explain intent, domain meaning, invariants, and runtime
69
+ consequences. Do not write comments that merely restate syntax.
70
+ - Keep comments truthful. If behavior changes, update or delete the stale
71
+ comment in the same patch.
72
+ - When docs mention versions, counts, routes, solver policy, or validation
73
+ expectations, verify those facts against the current code and tests in the
74
+ same turn.
75
+ - Prefer present-tense current-state docs after a refactor lands. Do not leave
76
+ future-tense planning language in repo docs unless the file is intentionally a
77
+ still-pending plan.
78
+ - When onboarding surfaces change, keep `README.md`, `WIREFRAME.md`, and this
79
+ file aligned.
80
+
81
+ The standard to aim for is: a new reader should be able to understand why a
82
+ piece of code exists before they need to understand every line of how it works.
83
+
84
+ The bundle-level `.github/workflows/ci.yml` installs browser dependencies and
85
+ runs the root `make ci-local`, which dispatches this app's standard checks. The
86
+ app Makefile remains the authoritative standalone and Hugging Face Space
87
+ validation surface, especially `make ci-local` and `make pre-release`.
88
+
89
+ ## Testing Guidelines
90
+
91
+ Add Rust tests next to the behavior they protect, usually in `src/...`
92
+ `#[cfg(test)]` modules. Frontend behavior belongs in `tests/frontend/` and
93
+ should use the existing fake DOM support in `tests/support/`; real browser
94
+ flows belong in `tests/e2e/`. If you change solver behavior, run both
95
+ `cargo test` and the ignored large-demo solve. If you change UI modules, run
96
+ the Node syntax check, frontend tests, and Playwright tests.
97
+
98
+ ## Commit & Pull Request Guidelines
99
+
100
+ Follow the workspace commit style seen upstream: conventional prefixes such as
101
+ `fix(...)`, `feat(...)`, `refactor(...)`, `test(...)`, and `chore(...)` (for
102
+ example, `fix(runtime): route pure scalar construction to descriptor path`).
103
+ PRs should state user-visible impact, changed config or API surface, and the
104
+ exact validation commands run. Include screenshots only for visible UI changes.
105
+
106
+ ## Configuration & Runtime Notes
107
+
108
+ `solver.toml` is embedded from `src/domain/plan.rs` via
109
+ `#[planning_solution(..., solver_toml = "../../solver.toml")]`; treat it as
110
+ the runtime source of truth. Keep `solverforge.app.toml`,
111
+ `static/sf-config.json`, and Docker/runtime port settings aligned with any port
112
+ or route changes.
CHANGELOG.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this use case are documented in this file.
4
+
5
+ ## 2.0.6 (2026-07-29)
6
+
7
+ ### Maintenance
8
+
9
+ * **release:** target SolverForge 0.19.3.
10
+
11
+ ## 2.0.5 (2026-07-17)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **hospital:** target SolverForge 0.19.0 88f38bd
17
+
18
+ ## 2.0.4 (2026-07-13)
19
+
20
+ ### Maintenance
21
+
22
+ * **release:** target SolverForge 0.18.0.
23
+ * **tests:** align zero-work retained-job coverage with the unified solver lifecycle.
24
+ * **docs:** align CI, browser boot, event payload, and solver-policy descriptions with code.
25
+ * **metadata:** use the canonical uppercase `LARGE` demo id.
26
+
27
+ ## 2.0.3 (2026-06-16)
28
+
29
+ ### Maintenance
30
+
31
+ * **release:** target SolverForge 0.17.1 and solverforge-cli 2.2.2.
32
+ * **metadata:** publish the hospital facts, entities, variables, and constraints in app metadata.
33
+
34
+ ## 2.0.2 (2026-05-28)
35
+
36
+ ### Maintenance
37
+
38
+ * **release:** target SolverForge 0.15.0.
39
+
40
+ ## 2.0.1 (2026-05-16)
41
+
42
+ ### Maintenance
43
+
44
+ * **release:** target SolverForge 0.14.1.
45
+
46
+ ## 2.0.0 (2026-05-14)
47
+
48
+ ### Maintenance
49
+
50
+ * **release:** set the public app release line to 2.0.0 across Cargo metadata and release validation.
51
+
52
+ ## 1.0.2 (2026-05-14)
53
+
54
+ ### Maintenance
55
+
56
+ * **release:** align the bundled app with SolverForge 0.13.1 and solverforge-ui 0.6.5.
57
+ * **docs:** standardize the use-case README and add beginner-facing SolverForge maintenance notes.
58
+
59
+ ## 1.0.1 (2026-04-26)
60
+
61
+
62
+ ### Features
63
+
64
+ * **app:** add hospital scheduling application b7e7f16
65
+
66
+
67
+ ### Bug Fixes
68
+
69
+ * **space:** satisfy Hugging Face metadata validation 470faf8
Cargo.lock ADDED
@@ -0,0 +1,1628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "aho-corasick"
7
+ version = "1.1.4"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10
+ dependencies = [
11
+ "memchr",
12
+ ]
13
+
14
+ [[package]]
15
+ name = "android_system_properties"
16
+ version = "0.1.5"
17
+ source = "registry+https://github.com/rust-lang/crates.io-index"
18
+ checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
19
+ dependencies = [
20
+ "libc",
21
+ ]
22
+
23
+ [[package]]
24
+ name = "anyhow"
25
+ version = "1.0.102"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
28
+
29
+ [[package]]
30
+ name = "arrayvec"
31
+ version = "0.7.6"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
34
+
35
+ [[package]]
36
+ name = "atomic-waker"
37
+ version = "1.1.2"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
40
+
41
+ [[package]]
42
+ name = "autocfg"
43
+ version = "1.5.1"
44
+ source = "registry+https://github.com/rust-lang/crates.io-index"
45
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
46
+
47
+ [[package]]
48
+ name = "axum"
49
+ version = "0.8.9"
50
+ source = "registry+https://github.com/rust-lang/crates.io-index"
51
+ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
52
+ dependencies = [
53
+ "axum-core",
54
+ "bytes",
55
+ "form_urlencoded",
56
+ "futures-util",
57
+ "http",
58
+ "http-body",
59
+ "http-body-util",
60
+ "hyper",
61
+ "hyper-util",
62
+ "itoa",
63
+ "matchit",
64
+ "memchr",
65
+ "mime",
66
+ "percent-encoding",
67
+ "pin-project-lite",
68
+ "serde_core",
69
+ "serde_json",
70
+ "serde_path_to_error",
71
+ "serde_urlencoded",
72
+ "sync_wrapper",
73
+ "tokio",
74
+ "tower",
75
+ "tower-layer",
76
+ "tower-service",
77
+ "tracing",
78
+ ]
79
+
80
+ [[package]]
81
+ name = "axum-core"
82
+ version = "0.5.6"
83
+ source = "registry+https://github.com/rust-lang/crates.io-index"
84
+ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
85
+ dependencies = [
86
+ "bytes",
87
+ "futures-core",
88
+ "http",
89
+ "http-body",
90
+ "http-body-util",
91
+ "mime",
92
+ "pin-project-lite",
93
+ "sync_wrapper",
94
+ "tower-layer",
95
+ "tower-service",
96
+ "tracing",
97
+ ]
98
+
99
+ [[package]]
100
+ name = "bitflags"
101
+ version = "2.13.0"
102
+ source = "registry+https://github.com/rust-lang/crates.io-index"
103
+ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
104
+
105
+ [[package]]
106
+ name = "bumpalo"
107
+ version = "3.20.3"
108
+ source = "registry+https://github.com/rust-lang/crates.io-index"
109
+ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
110
+
111
+ [[package]]
112
+ name = "bytes"
113
+ version = "1.11.1"
114
+ source = "registry+https://github.com/rust-lang/crates.io-index"
115
+ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
116
+
117
+ [[package]]
118
+ name = "cc"
119
+ version = "1.2.64"
120
+ source = "registry+https://github.com/rust-lang/crates.io-index"
121
+ checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
122
+ dependencies = [
123
+ "find-msvc-tools",
124
+ "shlex",
125
+ ]
126
+
127
+ [[package]]
128
+ name = "cfg-if"
129
+ version = "1.0.4"
130
+ source = "registry+https://github.com/rust-lang/crates.io-index"
131
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
132
+
133
+ [[package]]
134
+ name = "chacha20"
135
+ version = "0.10.0"
136
+ source = "registry+https://github.com/rust-lang/crates.io-index"
137
+ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
138
+ dependencies = [
139
+ "cfg-if",
140
+ "cpufeatures",
141
+ "rand_core",
142
+ ]
143
+
144
+ [[package]]
145
+ name = "chrono"
146
+ version = "0.4.45"
147
+ source = "registry+https://github.com/rust-lang/crates.io-index"
148
+ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
149
+ dependencies = [
150
+ "iana-time-zone",
151
+ "js-sys",
152
+ "num-traits",
153
+ "serde",
154
+ "wasm-bindgen",
155
+ "windows-link",
156
+ ]
157
+
158
+ [[package]]
159
+ name = "core-foundation-sys"
160
+ version = "0.8.7"
161
+ source = "registry+https://github.com/rust-lang/crates.io-index"
162
+ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
163
+
164
+ [[package]]
165
+ name = "cpufeatures"
166
+ version = "0.3.0"
167
+ source = "registry+https://github.com/rust-lang/crates.io-index"
168
+ checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
169
+ dependencies = [
170
+ "libc",
171
+ ]
172
+
173
+ [[package]]
174
+ name = "crossbeam-deque"
175
+ version = "0.8.6"
176
+ source = "registry+https://github.com/rust-lang/crates.io-index"
177
+ checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
178
+ dependencies = [
179
+ "crossbeam-epoch",
180
+ "crossbeam-utils",
181
+ ]
182
+
183
+ [[package]]
184
+ name = "crossbeam-epoch"
185
+ version = "0.9.18"
186
+ source = "registry+https://github.com/rust-lang/crates.io-index"
187
+ checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
188
+ dependencies = [
189
+ "crossbeam-utils",
190
+ ]
191
+
192
+ [[package]]
193
+ name = "crossbeam-utils"
194
+ version = "0.8.21"
195
+ source = "registry+https://github.com/rust-lang/crates.io-index"
196
+ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
197
+
198
+ [[package]]
199
+ name = "either"
200
+ version = "1.16.0"
201
+ source = "registry+https://github.com/rust-lang/crates.io-index"
202
+ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
203
+
204
+ [[package]]
205
+ name = "equivalent"
206
+ version = "1.0.2"
207
+ source = "registry+https://github.com/rust-lang/crates.io-index"
208
+ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
209
+
210
+ [[package]]
211
+ name = "errno"
212
+ version = "0.3.14"
213
+ source = "registry+https://github.com/rust-lang/crates.io-index"
214
+ checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
215
+ dependencies = [
216
+ "libc",
217
+ "windows-sys",
218
+ ]
219
+
220
+ [[package]]
221
+ name = "find-msvc-tools"
222
+ version = "0.1.9"
223
+ source = "registry+https://github.com/rust-lang/crates.io-index"
224
+ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
225
+
226
+ [[package]]
227
+ name = "foldhash"
228
+ version = "0.1.5"
229
+ source = "registry+https://github.com/rust-lang/crates.io-index"
230
+ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
231
+
232
+ [[package]]
233
+ name = "form_urlencoded"
234
+ version = "1.2.2"
235
+ source = "registry+https://github.com/rust-lang/crates.io-index"
236
+ checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
237
+ dependencies = [
238
+ "percent-encoding",
239
+ ]
240
+
241
+ [[package]]
242
+ name = "futures-channel"
243
+ version = "0.3.32"
244
+ source = "registry+https://github.com/rust-lang/crates.io-index"
245
+ checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
246
+ dependencies = [
247
+ "futures-core",
248
+ ]
249
+
250
+ [[package]]
251
+ name = "futures-core"
252
+ version = "0.3.32"
253
+ source = "registry+https://github.com/rust-lang/crates.io-index"
254
+ checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
255
+
256
+ [[package]]
257
+ name = "futures-sink"
258
+ version = "0.3.32"
259
+ source = "registry+https://github.com/rust-lang/crates.io-index"
260
+ checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
261
+
262
+ [[package]]
263
+ name = "futures-task"
264
+ version = "0.3.32"
265
+ source = "registry+https://github.com/rust-lang/crates.io-index"
266
+ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
267
+
268
+ [[package]]
269
+ name = "futures-util"
270
+ version = "0.3.32"
271
+ source = "registry+https://github.com/rust-lang/crates.io-index"
272
+ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
273
+ dependencies = [
274
+ "futures-core",
275
+ "futures-task",
276
+ "pin-project-lite",
277
+ "slab",
278
+ ]
279
+
280
+ [[package]]
281
+ name = "getrandom"
282
+ version = "0.4.2"
283
+ source = "registry+https://github.com/rust-lang/crates.io-index"
284
+ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
285
+ dependencies = [
286
+ "cfg-if",
287
+ "libc",
288
+ "r-efi",
289
+ "rand_core",
290
+ "wasip2",
291
+ "wasip3",
292
+ ]
293
+
294
+ [[package]]
295
+ name = "hashbrown"
296
+ version = "0.15.5"
297
+ source = "registry+https://github.com/rust-lang/crates.io-index"
298
+ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
299
+ dependencies = [
300
+ "foldhash",
301
+ ]
302
+
303
+ [[package]]
304
+ name = "hashbrown"
305
+ version = "0.17.1"
306
+ source = "registry+https://github.com/rust-lang/crates.io-index"
307
+ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
308
+
309
+ [[package]]
310
+ name = "heck"
311
+ version = "0.5.0"
312
+ source = "registry+https://github.com/rust-lang/crates.io-index"
313
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
314
+
315
+ [[package]]
316
+ name = "http"
317
+ version = "1.4.2"
318
+ source = "registry+https://github.com/rust-lang/crates.io-index"
319
+ checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
320
+ dependencies = [
321
+ "bytes",
322
+ "itoa",
323
+ ]
324
+
325
+ [[package]]
326
+ name = "http-body"
327
+ version = "1.0.1"
328
+ source = "registry+https://github.com/rust-lang/crates.io-index"
329
+ checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
330
+ dependencies = [
331
+ "bytes",
332
+ "http",
333
+ ]
334
+
335
+ [[package]]
336
+ name = "http-body-util"
337
+ version = "0.1.3"
338
+ source = "registry+https://github.com/rust-lang/crates.io-index"
339
+ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
340
+ dependencies = [
341
+ "bytes",
342
+ "futures-core",
343
+ "http",
344
+ "http-body",
345
+ "pin-project-lite",
346
+ ]
347
+
348
+ [[package]]
349
+ name = "http-range-header"
350
+ version = "0.4.2"
351
+ source = "registry+https://github.com/rust-lang/crates.io-index"
352
+ checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
353
+
354
+ [[package]]
355
+ name = "httparse"
356
+ version = "1.10.1"
357
+ source = "registry+https://github.com/rust-lang/crates.io-index"
358
+ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
359
+
360
+ [[package]]
361
+ name = "httpdate"
362
+ version = "1.0.3"
363
+ source = "registry+https://github.com/rust-lang/crates.io-index"
364
+ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
365
+
366
+ [[package]]
367
+ name = "hyper"
368
+ version = "1.10.1"
369
+ source = "registry+https://github.com/rust-lang/crates.io-index"
370
+ checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
371
+ dependencies = [
372
+ "atomic-waker",
373
+ "bytes",
374
+ "futures-channel",
375
+ "futures-core",
376
+ "http",
377
+ "http-body",
378
+ "httparse",
379
+ "httpdate",
380
+ "itoa",
381
+ "pin-project-lite",
382
+ "smallvec",
383
+ "tokio",
384
+ ]
385
+
386
+ [[package]]
387
+ name = "hyper-util"
388
+ version = "0.1.20"
389
+ source = "registry+https://github.com/rust-lang/crates.io-index"
390
+ checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
391
+ dependencies = [
392
+ "bytes",
393
+ "http",
394
+ "http-body",
395
+ "hyper",
396
+ "pin-project-lite",
397
+ "tokio",
398
+ "tower-service",
399
+ ]
400
+
401
+ [[package]]
402
+ name = "iana-time-zone"
403
+ version = "0.1.65"
404
+ source = "registry+https://github.com/rust-lang/crates.io-index"
405
+ checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
406
+ dependencies = [
407
+ "android_system_properties",
408
+ "core-foundation-sys",
409
+ "iana-time-zone-haiku",
410
+ "js-sys",
411
+ "log",
412
+ "wasm-bindgen",
413
+ "windows-core",
414
+ ]
415
+
416
+ [[package]]
417
+ name = "iana-time-zone-haiku"
418
+ version = "0.1.2"
419
+ source = "registry+https://github.com/rust-lang/crates.io-index"
420
+ checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
421
+ dependencies = [
422
+ "cc",
423
+ ]
424
+
425
+ [[package]]
426
+ name = "id-arena"
427
+ version = "2.3.0"
428
+ source = "registry+https://github.com/rust-lang/crates.io-index"
429
+ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
430
+
431
+ [[package]]
432
+ name = "include_dir"
433
+ version = "0.7.4"
434
+ source = "registry+https://github.com/rust-lang/crates.io-index"
435
+ checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
436
+ dependencies = [
437
+ "include_dir_macros",
438
+ ]
439
+
440
+ [[package]]
441
+ name = "include_dir_macros"
442
+ version = "0.7.4"
443
+ source = "registry+https://github.com/rust-lang/crates.io-index"
444
+ checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
445
+ dependencies = [
446
+ "proc-macro2",
447
+ "quote",
448
+ ]
449
+
450
+ [[package]]
451
+ name = "indexmap"
452
+ version = "2.14.0"
453
+ source = "registry+https://github.com/rust-lang/crates.io-index"
454
+ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
455
+ dependencies = [
456
+ "equivalent",
457
+ "hashbrown 0.17.1",
458
+ "serde",
459
+ "serde_core",
460
+ ]
461
+
462
+ [[package]]
463
+ name = "itoa"
464
+ version = "1.0.18"
465
+ source = "registry+https://github.com/rust-lang/crates.io-index"
466
+ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
467
+
468
+ [[package]]
469
+ name = "js-sys"
470
+ version = "0.3.100"
471
+ source = "registry+https://github.com/rust-lang/crates.io-index"
472
+ checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
473
+ dependencies = [
474
+ "cfg-if",
475
+ "futures-util",
476
+ "wasm-bindgen",
477
+ ]
478
+
479
+ [[package]]
480
+ name = "lazy_static"
481
+ version = "1.5.0"
482
+ source = "registry+https://github.com/rust-lang/crates.io-index"
483
+ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
484
+
485
+ [[package]]
486
+ name = "leb128fmt"
487
+ version = "0.1.0"
488
+ source = "registry+https://github.com/rust-lang/crates.io-index"
489
+ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
490
+
491
+ [[package]]
492
+ name = "libc"
493
+ version = "0.2.186"
494
+ source = "registry+https://github.com/rust-lang/crates.io-index"
495
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
496
+
497
+ [[package]]
498
+ name = "lock_api"
499
+ version = "0.4.14"
500
+ source = "registry+https://github.com/rust-lang/crates.io-index"
501
+ checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
502
+ dependencies = [
503
+ "scopeguard",
504
+ ]
505
+
506
+ [[package]]
507
+ name = "log"
508
+ version = "0.4.32"
509
+ source = "registry+https://github.com/rust-lang/crates.io-index"
510
+ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
511
+
512
+ [[package]]
513
+ name = "matchers"
514
+ version = "0.2.0"
515
+ source = "registry+https://github.com/rust-lang/crates.io-index"
516
+ checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
517
+ dependencies = [
518
+ "regex-automata",
519
+ ]
520
+
521
+ [[package]]
522
+ name = "matchit"
523
+ version = "0.8.4"
524
+ source = "registry+https://github.com/rust-lang/crates.io-index"
525
+ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
526
+
527
+ [[package]]
528
+ name = "memchr"
529
+ version = "2.8.2"
530
+ source = "registry+https://github.com/rust-lang/crates.io-index"
531
+ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
532
+
533
+ [[package]]
534
+ name = "mime"
535
+ version = "0.3.17"
536
+ source = "registry+https://github.com/rust-lang/crates.io-index"
537
+ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
538
+
539
+ [[package]]
540
+ name = "mime_guess"
541
+ version = "2.0.5"
542
+ source = "registry+https://github.com/rust-lang/crates.io-index"
543
+ checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
544
+ dependencies = [
545
+ "mime",
546
+ "unicase",
547
+ ]
548
+
549
+ [[package]]
550
+ name = "mio"
551
+ version = "1.2.1"
552
+ source = "registry+https://github.com/rust-lang/crates.io-index"
553
+ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
554
+ dependencies = [
555
+ "libc",
556
+ "wasi",
557
+ "windows-sys",
558
+ ]
559
+
560
+ [[package]]
561
+ name = "nu-ansi-term"
562
+ version = "0.50.3"
563
+ source = "registry+https://github.com/rust-lang/crates.io-index"
564
+ checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
565
+ dependencies = [
566
+ "windows-sys",
567
+ ]
568
+
569
+ [[package]]
570
+ name = "num-format"
571
+ version = "0.4.4"
572
+ source = "registry+https://github.com/rust-lang/crates.io-index"
573
+ checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
574
+ dependencies = [
575
+ "arrayvec",
576
+ "itoa",
577
+ ]
578
+
579
+ [[package]]
580
+ name = "num-traits"
581
+ version = "0.2.19"
582
+ source = "registry+https://github.com/rust-lang/crates.io-index"
583
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
584
+ dependencies = [
585
+ "autocfg",
586
+ ]
587
+
588
+ [[package]]
589
+ name = "once_cell"
590
+ version = "1.21.4"
591
+ source = "registry+https://github.com/rust-lang/crates.io-index"
592
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
593
+
594
+ [[package]]
595
+ name = "owo-colors"
596
+ version = "4.3.0"
597
+ source = "registry+https://github.com/rust-lang/crates.io-index"
598
+ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
599
+
600
+ [[package]]
601
+ name = "parking_lot"
602
+ version = "0.12.5"
603
+ source = "registry+https://github.com/rust-lang/crates.io-index"
604
+ checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
605
+ dependencies = [
606
+ "lock_api",
607
+ "parking_lot_core",
608
+ ]
609
+
610
+ [[package]]
611
+ name = "parking_lot_core"
612
+ version = "0.9.12"
613
+ source = "registry+https://github.com/rust-lang/crates.io-index"
614
+ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
615
+ dependencies = [
616
+ "cfg-if",
617
+ "libc",
618
+ "redox_syscall",
619
+ "smallvec",
620
+ "windows-link",
621
+ ]
622
+
623
+ [[package]]
624
+ name = "percent-encoding"
625
+ version = "2.3.2"
626
+ source = "registry+https://github.com/rust-lang/crates.io-index"
627
+ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
628
+
629
+ [[package]]
630
+ name = "pin-project-lite"
631
+ version = "0.2.17"
632
+ source = "registry+https://github.com/rust-lang/crates.io-index"
633
+ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
634
+
635
+ [[package]]
636
+ name = "ppv-lite86"
637
+ version = "0.2.21"
638
+ source = "registry+https://github.com/rust-lang/crates.io-index"
639
+ checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
640
+ dependencies = [
641
+ "zerocopy",
642
+ ]
643
+
644
+ [[package]]
645
+ name = "prettyplease"
646
+ version = "0.2.37"
647
+ source = "registry+https://github.com/rust-lang/crates.io-index"
648
+ checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
649
+ dependencies = [
650
+ "proc-macro2",
651
+ "syn",
652
+ ]
653
+
654
+ [[package]]
655
+ name = "proc-macro2"
656
+ version = "1.0.106"
657
+ source = "registry+https://github.com/rust-lang/crates.io-index"
658
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
659
+ dependencies = [
660
+ "unicode-ident",
661
+ ]
662
+
663
+ [[package]]
664
+ name = "quote"
665
+ version = "1.0.45"
666
+ source = "registry+https://github.com/rust-lang/crates.io-index"
667
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
668
+ dependencies = [
669
+ "proc-macro2",
670
+ ]
671
+
672
+ [[package]]
673
+ name = "r-efi"
674
+ version = "6.0.0"
675
+ source = "registry+https://github.com/rust-lang/crates.io-index"
676
+ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
677
+
678
+ [[package]]
679
+ name = "rand"
680
+ version = "0.10.1"
681
+ source = "registry+https://github.com/rust-lang/crates.io-index"
682
+ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
683
+ dependencies = [
684
+ "chacha20",
685
+ "getrandom",
686
+ "rand_core",
687
+ ]
688
+
689
+ [[package]]
690
+ name = "rand_chacha"
691
+ version = "0.10.0"
692
+ source = "registry+https://github.com/rust-lang/crates.io-index"
693
+ checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
694
+ dependencies = [
695
+ "ppv-lite86",
696
+ "rand_core",
697
+ ]
698
+
699
+ [[package]]
700
+ name = "rand_core"
701
+ version = "0.10.1"
702
+ source = "registry+https://github.com/rust-lang/crates.io-index"
703
+ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
704
+
705
+ [[package]]
706
+ name = "rayon"
707
+ version = "1.12.0"
708
+ source = "registry+https://github.com/rust-lang/crates.io-index"
709
+ checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
710
+ dependencies = [
711
+ "either",
712
+ "rayon-core",
713
+ ]
714
+
715
+ [[package]]
716
+ name = "rayon-core"
717
+ version = "1.13.0"
718
+ source = "registry+https://github.com/rust-lang/crates.io-index"
719
+ checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
720
+ dependencies = [
721
+ "crossbeam-deque",
722
+ "crossbeam-utils",
723
+ ]
724
+
725
+ [[package]]
726
+ name = "redox_syscall"
727
+ version = "0.5.18"
728
+ source = "registry+https://github.com/rust-lang/crates.io-index"
729
+ checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
730
+ dependencies = [
731
+ "bitflags",
732
+ ]
733
+
734
+ [[package]]
735
+ name = "regex-automata"
736
+ version = "0.4.14"
737
+ source = "registry+https://github.com/rust-lang/crates.io-index"
738
+ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
739
+ dependencies = [
740
+ "aho-corasick",
741
+ "memchr",
742
+ "regex-syntax",
743
+ ]
744
+
745
+ [[package]]
746
+ name = "regex-syntax"
747
+ version = "0.8.11"
748
+ source = "registry+https://github.com/rust-lang/crates.io-index"
749
+ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
750
+
751
+ [[package]]
752
+ name = "rustversion"
753
+ version = "1.0.22"
754
+ source = "registry+https://github.com/rust-lang/crates.io-index"
755
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
756
+
757
+ [[package]]
758
+ name = "ryu"
759
+ version = "1.0.23"
760
+ source = "registry+https://github.com/rust-lang/crates.io-index"
761
+ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
762
+
763
+ [[package]]
764
+ name = "scopeguard"
765
+ version = "1.2.0"
766
+ source = "registry+https://github.com/rust-lang/crates.io-index"
767
+ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
768
+
769
+ [[package]]
770
+ name = "semver"
771
+ version = "1.0.28"
772
+ source = "registry+https://github.com/rust-lang/crates.io-index"
773
+ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
774
+
775
+ [[package]]
776
+ name = "serde"
777
+ version = "1.0.228"
778
+ source = "registry+https://github.com/rust-lang/crates.io-index"
779
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
780
+ dependencies = [
781
+ "serde_core",
782
+ "serde_derive",
783
+ ]
784
+
785
+ [[package]]
786
+ name = "serde_core"
787
+ version = "1.0.228"
788
+ source = "registry+https://github.com/rust-lang/crates.io-index"
789
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
790
+ dependencies = [
791
+ "serde_derive",
792
+ ]
793
+
794
+ [[package]]
795
+ name = "serde_derive"
796
+ version = "1.0.228"
797
+ source = "registry+https://github.com/rust-lang/crates.io-index"
798
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
799
+ dependencies = [
800
+ "proc-macro2",
801
+ "quote",
802
+ "syn",
803
+ ]
804
+
805
+ [[package]]
806
+ name = "serde_json"
807
+ version = "1.0.150"
808
+ source = "registry+https://github.com/rust-lang/crates.io-index"
809
+ checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
810
+ dependencies = [
811
+ "itoa",
812
+ "memchr",
813
+ "serde",
814
+ "serde_core",
815
+ "zmij",
816
+ ]
817
+
818
+ [[package]]
819
+ name = "serde_path_to_error"
820
+ version = "0.1.20"
821
+ source = "registry+https://github.com/rust-lang/crates.io-index"
822
+ checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
823
+ dependencies = [
824
+ "itoa",
825
+ "serde",
826
+ "serde_core",
827
+ ]
828
+
829
+ [[package]]
830
+ name = "serde_spanned"
831
+ version = "1.1.1"
832
+ source = "registry+https://github.com/rust-lang/crates.io-index"
833
+ checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
834
+ dependencies = [
835
+ "serde_core",
836
+ ]
837
+
838
+ [[package]]
839
+ name = "serde_urlencoded"
840
+ version = "0.7.1"
841
+ source = "registry+https://github.com/rust-lang/crates.io-index"
842
+ checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
843
+ dependencies = [
844
+ "form_urlencoded",
845
+ "itoa",
846
+ "ryu",
847
+ "serde",
848
+ ]
849
+
850
+ [[package]]
851
+ name = "serde_yaml"
852
+ version = "0.9.34+deprecated"
853
+ source = "registry+https://github.com/rust-lang/crates.io-index"
854
+ checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
855
+ dependencies = [
856
+ "indexmap",
857
+ "itoa",
858
+ "ryu",
859
+ "serde",
860
+ "unsafe-libyaml",
861
+ ]
862
+
863
+ [[package]]
864
+ name = "sharded-slab"
865
+ version = "0.1.7"
866
+ source = "registry+https://github.com/rust-lang/crates.io-index"
867
+ checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
868
+ dependencies = [
869
+ "lazy_static",
870
+ ]
871
+
872
+ [[package]]
873
+ name = "shlex"
874
+ version = "2.0.1"
875
+ source = "registry+https://github.com/rust-lang/crates.io-index"
876
+ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
877
+
878
+ [[package]]
879
+ name = "signal-hook-registry"
880
+ version = "1.4.8"
881
+ source = "registry+https://github.com/rust-lang/crates.io-index"
882
+ checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
883
+ dependencies = [
884
+ "errno",
885
+ "libc",
886
+ ]
887
+
888
+ [[package]]
889
+ name = "slab"
890
+ version = "0.4.12"
891
+ source = "registry+https://github.com/rust-lang/crates.io-index"
892
+ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
893
+
894
+ [[package]]
895
+ name = "smallvec"
896
+ version = "1.15.2"
897
+ source = "registry+https://github.com/rust-lang/crates.io-index"
898
+ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
899
+
900
+ [[package]]
901
+ name = "socket2"
902
+ version = "0.6.4"
903
+ source = "registry+https://github.com/rust-lang/crates.io-index"
904
+ checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
905
+ dependencies = [
906
+ "libc",
907
+ "windows-sys",
908
+ ]
909
+
910
+ [[package]]
911
+ name = "solverforge"
912
+ version = "0.19.3"
913
+ source = "registry+https://github.com/rust-lang/crates.io-index"
914
+ checksum = "15d0a5dfb480a56ec3ee9aa048fd54bba3e65b04866bde610c1a956b89b4da80"
915
+ dependencies = [
916
+ "solverforge-bridge",
917
+ "solverforge-config",
918
+ "solverforge-console",
919
+ "solverforge-core",
920
+ "solverforge-cvrp",
921
+ "solverforge-macros",
922
+ "solverforge-scoring",
923
+ "solverforge-solver",
924
+ ]
925
+
926
+ [[package]]
927
+ name = "solverforge-bridge"
928
+ version = "0.19.3"
929
+ source = "registry+https://github.com/rust-lang/crates.io-index"
930
+ checksum = "c78f964f79318c3c0ee3161ed3004c708f04c4a751f784fe7d0ccc908194aaa3"
931
+ dependencies = [
932
+ "solverforge-config",
933
+ "solverforge-core",
934
+ "solverforge-scoring",
935
+ "solverforge-solver",
936
+ ]
937
+
938
+ [[package]]
939
+ name = "solverforge-config"
940
+ version = "0.19.3"
941
+ source = "registry+https://github.com/rust-lang/crates.io-index"
942
+ checksum = "27d4cb13eb5097514c5d18eeddb0f88521e3f3d8d402a0d22ffbbdcd08c90c21"
943
+ dependencies = [
944
+ "serde",
945
+ "serde_yaml",
946
+ "solverforge-core",
947
+ "thiserror",
948
+ "toml",
949
+ ]
950
+
951
+ [[package]]
952
+ name = "solverforge-console"
953
+ version = "0.19.3"
954
+ source = "registry+https://github.com/rust-lang/crates.io-index"
955
+ checksum = "3f64e369266512f53d2bd335592975193fdc4d8c2d6da99929f152edfd721562"
956
+ dependencies = [
957
+ "num-format",
958
+ "owo-colors",
959
+ "tracing",
960
+ "tracing-subscriber",
961
+ ]
962
+
963
+ [[package]]
964
+ name = "solverforge-core"
965
+ version = "0.19.3"
966
+ source = "registry+https://github.com/rust-lang/crates.io-index"
967
+ checksum = "e6118e7d98c3c8ef58646b2779603dd1ab6f9dc9f25481cb104402cbcd06be46"
968
+ dependencies = [
969
+ "serde",
970
+ "thiserror",
971
+ ]
972
+
973
+ [[package]]
974
+ name = "solverforge-cvrp"
975
+ version = "0.19.3"
976
+ source = "registry+https://github.com/rust-lang/crates.io-index"
977
+ checksum = "554c12afb5908f396833688be18e649eee5967df8c41c0e4f20f0a10445ff651"
978
+ dependencies = [
979
+ "solverforge-solver",
980
+ ]
981
+
982
+ [[package]]
983
+ name = "solverforge-hospital"
984
+ version = "2.0.6"
985
+ dependencies = [
986
+ "axum",
987
+ "chrono",
988
+ "parking_lot",
989
+ "rand",
990
+ "serde",
991
+ "serde_json",
992
+ "solverforge",
993
+ "solverforge-ui",
994
+ "tokio",
995
+ "tokio-stream",
996
+ "tower",
997
+ "tower-http",
998
+ ]
999
+
1000
+ [[package]]
1001
+ name = "solverforge-macros"
1002
+ version = "0.19.3"
1003
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1004
+ checksum = "22c15f305806b185fc4815f8da0ec73acb9acbba5de3583433ab7b6bb2eeffa4"
1005
+ dependencies = [
1006
+ "proc-macro2",
1007
+ "quote",
1008
+ "syn",
1009
+ ]
1010
+
1011
+ [[package]]
1012
+ name = "solverforge-scoring"
1013
+ version = "0.19.3"
1014
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1015
+ checksum = "3ddf9f9af52b0d2525f581f921e41fac8352dc2f6f1deb7f9100aff9cfedba25"
1016
+ dependencies = [
1017
+ "solverforge-core",
1018
+ "thiserror",
1019
+ ]
1020
+
1021
+ [[package]]
1022
+ name = "solverforge-solver"
1023
+ version = "0.19.3"
1024
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1025
+ checksum = "19d30aece798a48d08edc630734a181dd2d9bcc1e806de7ec27701f9885989a6"
1026
+ dependencies = [
1027
+ "rand",
1028
+ "rand_chacha",
1029
+ "rayon",
1030
+ "serde",
1031
+ "smallvec",
1032
+ "solverforge-config",
1033
+ "solverforge-core",
1034
+ "solverforge-scoring",
1035
+ "thiserror",
1036
+ "tokio",
1037
+ "tracing",
1038
+ ]
1039
+
1040
+ [[package]]
1041
+ name = "solverforge-ui"
1042
+ version = "0.6.5"
1043
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1044
+ checksum = "1c7fa2d78c84af9a1e264adcffc1bdf8cb4edab8d73a3543fb448d166c95596f"
1045
+ dependencies = [
1046
+ "axum",
1047
+ "include_dir",
1048
+ ]
1049
+
1050
+ [[package]]
1051
+ name = "syn"
1052
+ version = "2.0.117"
1053
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1054
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
1055
+ dependencies = [
1056
+ "proc-macro2",
1057
+ "quote",
1058
+ "unicode-ident",
1059
+ ]
1060
+
1061
+ [[package]]
1062
+ name = "sync_wrapper"
1063
+ version = "1.0.2"
1064
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1065
+ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1066
+
1067
+ [[package]]
1068
+ name = "thiserror"
1069
+ version = "2.0.18"
1070
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1071
+ checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1072
+ dependencies = [
1073
+ "thiserror-impl",
1074
+ ]
1075
+
1076
+ [[package]]
1077
+ name = "thiserror-impl"
1078
+ version = "2.0.18"
1079
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1080
+ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1081
+ dependencies = [
1082
+ "proc-macro2",
1083
+ "quote",
1084
+ "syn",
1085
+ ]
1086
+
1087
+ [[package]]
1088
+ name = "thread_local"
1089
+ version = "1.1.9"
1090
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1091
+ checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
1092
+ dependencies = [
1093
+ "cfg-if",
1094
+ ]
1095
+
1096
+ [[package]]
1097
+ name = "tokio"
1098
+ version = "1.52.3"
1099
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1100
+ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
1101
+ dependencies = [
1102
+ "bytes",
1103
+ "libc",
1104
+ "mio",
1105
+ "parking_lot",
1106
+ "pin-project-lite",
1107
+ "signal-hook-registry",
1108
+ "socket2",
1109
+ "tokio-macros",
1110
+ "windows-sys",
1111
+ ]
1112
+
1113
+ [[package]]
1114
+ name = "tokio-macros"
1115
+ version = "2.7.0"
1116
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1117
+ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
1118
+ dependencies = [
1119
+ "proc-macro2",
1120
+ "quote",
1121
+ "syn",
1122
+ ]
1123
+
1124
+ [[package]]
1125
+ name = "tokio-stream"
1126
+ version = "0.1.18"
1127
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1128
+ checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
1129
+ dependencies = [
1130
+ "futures-core",
1131
+ "pin-project-lite",
1132
+ "tokio",
1133
+ "tokio-util",
1134
+ ]
1135
+
1136
+ [[package]]
1137
+ name = "tokio-util"
1138
+ version = "0.7.18"
1139
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1140
+ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
1141
+ dependencies = [
1142
+ "bytes",
1143
+ "futures-core",
1144
+ "futures-sink",
1145
+ "pin-project-lite",
1146
+ "tokio",
1147
+ ]
1148
+
1149
+ [[package]]
1150
+ name = "toml"
1151
+ version = "1.1.2+spec-1.1.0"
1152
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1153
+ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
1154
+ dependencies = [
1155
+ "indexmap",
1156
+ "serde_core",
1157
+ "serde_spanned",
1158
+ "toml_datetime",
1159
+ "toml_parser",
1160
+ "toml_writer",
1161
+ "winnow",
1162
+ ]
1163
+
1164
+ [[package]]
1165
+ name = "toml_datetime"
1166
+ version = "1.1.1+spec-1.1.0"
1167
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1168
+ checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
1169
+ dependencies = [
1170
+ "serde_core",
1171
+ ]
1172
+
1173
+ [[package]]
1174
+ name = "toml_parser"
1175
+ version = "1.1.2+spec-1.1.0"
1176
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1177
+ checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
1178
+ dependencies = [
1179
+ "winnow",
1180
+ ]
1181
+
1182
+ [[package]]
1183
+ name = "toml_writer"
1184
+ version = "1.1.1+spec-1.1.0"
1185
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1186
+ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
1187
+
1188
+ [[package]]
1189
+ name = "tower"
1190
+ version = "0.5.3"
1191
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1192
+ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
1193
+ dependencies = [
1194
+ "futures-core",
1195
+ "futures-util",
1196
+ "pin-project-lite",
1197
+ "sync_wrapper",
1198
+ "tokio",
1199
+ "tower-layer",
1200
+ "tower-service",
1201
+ "tracing",
1202
+ ]
1203
+
1204
+ [[package]]
1205
+ name = "tower-http"
1206
+ version = "0.6.11"
1207
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1208
+ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
1209
+ dependencies = [
1210
+ "bitflags",
1211
+ "bytes",
1212
+ "futures-core",
1213
+ "futures-util",
1214
+ "http",
1215
+ "http-body",
1216
+ "http-body-util",
1217
+ "http-range-header",
1218
+ "httpdate",
1219
+ "mime",
1220
+ "mime_guess",
1221
+ "percent-encoding",
1222
+ "pin-project-lite",
1223
+ "tokio",
1224
+ "tokio-util",
1225
+ "tower-layer",
1226
+ "tower-service",
1227
+ ]
1228
+
1229
+ [[package]]
1230
+ name = "tower-layer"
1231
+ version = "0.3.3"
1232
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1233
+ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
1234
+
1235
+ [[package]]
1236
+ name = "tower-service"
1237
+ version = "0.3.3"
1238
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1239
+ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
1240
+
1241
+ [[package]]
1242
+ name = "tracing"
1243
+ version = "0.1.44"
1244
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1245
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1246
+ dependencies = [
1247
+ "log",
1248
+ "pin-project-lite",
1249
+ "tracing-attributes",
1250
+ "tracing-core",
1251
+ ]
1252
+
1253
+ [[package]]
1254
+ name = "tracing-attributes"
1255
+ version = "0.1.31"
1256
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1257
+ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1258
+ dependencies = [
1259
+ "proc-macro2",
1260
+ "quote",
1261
+ "syn",
1262
+ ]
1263
+
1264
+ [[package]]
1265
+ name = "tracing-core"
1266
+ version = "0.1.36"
1267
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1268
+ checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1269
+ dependencies = [
1270
+ "once_cell",
1271
+ "valuable",
1272
+ ]
1273
+
1274
+ [[package]]
1275
+ name = "tracing-log"
1276
+ version = "0.2.0"
1277
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1278
+ checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1279
+ dependencies = [
1280
+ "log",
1281
+ "once_cell",
1282
+ "tracing-core",
1283
+ ]
1284
+
1285
+ [[package]]
1286
+ name = "tracing-subscriber"
1287
+ version = "0.3.23"
1288
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1289
+ checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
1290
+ dependencies = [
1291
+ "matchers",
1292
+ "nu-ansi-term",
1293
+ "once_cell",
1294
+ "regex-automata",
1295
+ "sharded-slab",
1296
+ "smallvec",
1297
+ "thread_local",
1298
+ "tracing",
1299
+ "tracing-core",
1300
+ "tracing-log",
1301
+ ]
1302
+
1303
+ [[package]]
1304
+ name = "unicase"
1305
+ version = "2.9.0"
1306
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1307
+ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
1308
+
1309
+ [[package]]
1310
+ name = "unicode-ident"
1311
+ version = "1.0.24"
1312
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1313
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1314
+
1315
+ [[package]]
1316
+ name = "unicode-xid"
1317
+ version = "0.2.6"
1318
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1319
+ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
1320
+
1321
+ [[package]]
1322
+ name = "unsafe-libyaml"
1323
+ version = "0.2.11"
1324
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1325
+ checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
1326
+
1327
+ [[package]]
1328
+ name = "valuable"
1329
+ version = "0.1.1"
1330
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1331
+ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1332
+
1333
+ [[package]]
1334
+ name = "wasi"
1335
+ version = "0.11.1+wasi-snapshot-preview1"
1336
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1337
+ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1338
+
1339
+ [[package]]
1340
+ name = "wasip2"
1341
+ version = "1.0.3+wasi-0.2.9"
1342
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1343
+ checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
1344
+ dependencies = [
1345
+ "wit-bindgen 0.57.1",
1346
+ ]
1347
+
1348
+ [[package]]
1349
+ name = "wasip3"
1350
+ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
1351
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1352
+ checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
1353
+ dependencies = [
1354
+ "wit-bindgen 0.51.0",
1355
+ ]
1356
+
1357
+ [[package]]
1358
+ name = "wasm-bindgen"
1359
+ version = "0.2.123"
1360
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1361
+ checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
1362
+ dependencies = [
1363
+ "cfg-if",
1364
+ "once_cell",
1365
+ "rustversion",
1366
+ "wasm-bindgen-macro",
1367
+ "wasm-bindgen-shared",
1368
+ ]
1369
+
1370
+ [[package]]
1371
+ name = "wasm-bindgen-macro"
1372
+ version = "0.2.123"
1373
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1374
+ checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
1375
+ dependencies = [
1376
+ "quote",
1377
+ "wasm-bindgen-macro-support",
1378
+ ]
1379
+
1380
+ [[package]]
1381
+ name = "wasm-bindgen-macro-support"
1382
+ version = "0.2.123"
1383
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1384
+ checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
1385
+ dependencies = [
1386
+ "bumpalo",
1387
+ "proc-macro2",
1388
+ "quote",
1389
+ "syn",
1390
+ "wasm-bindgen-shared",
1391
+ ]
1392
+
1393
+ [[package]]
1394
+ name = "wasm-bindgen-shared"
1395
+ version = "0.2.123"
1396
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1397
+ checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
1398
+ dependencies = [
1399
+ "unicode-ident",
1400
+ ]
1401
+
1402
+ [[package]]
1403
+ name = "wasm-encoder"
1404
+ version = "0.244.0"
1405
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1406
+ checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
1407
+ dependencies = [
1408
+ "leb128fmt",
1409
+ "wasmparser",
1410
+ ]
1411
+
1412
+ [[package]]
1413
+ name = "wasm-metadata"
1414
+ version = "0.244.0"
1415
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1416
+ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
1417
+ dependencies = [
1418
+ "anyhow",
1419
+ "indexmap",
1420
+ "wasm-encoder",
1421
+ "wasmparser",
1422
+ ]
1423
+
1424
+ [[package]]
1425
+ name = "wasmparser"
1426
+ version = "0.244.0"
1427
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1428
+ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
1429
+ dependencies = [
1430
+ "bitflags",
1431
+ "hashbrown 0.15.5",
1432
+ "indexmap",
1433
+ "semver",
1434
+ ]
1435
+
1436
+ [[package]]
1437
+ name = "windows-core"
1438
+ version = "0.62.2"
1439
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1440
+ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
1441
+ dependencies = [
1442
+ "windows-implement",
1443
+ "windows-interface",
1444
+ "windows-link",
1445
+ "windows-result",
1446
+ "windows-strings",
1447
+ ]
1448
+
1449
+ [[package]]
1450
+ name = "windows-implement"
1451
+ version = "0.60.2"
1452
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1453
+ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
1454
+ dependencies = [
1455
+ "proc-macro2",
1456
+ "quote",
1457
+ "syn",
1458
+ ]
1459
+
1460
+ [[package]]
1461
+ name = "windows-interface"
1462
+ version = "0.59.3"
1463
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1464
+ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
1465
+ dependencies = [
1466
+ "proc-macro2",
1467
+ "quote",
1468
+ "syn",
1469
+ ]
1470
+
1471
+ [[package]]
1472
+ name = "windows-link"
1473
+ version = "0.2.1"
1474
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1475
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1476
+
1477
+ [[package]]
1478
+ name = "windows-result"
1479
+ version = "0.4.1"
1480
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1481
+ checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
1482
+ dependencies = [
1483
+ "windows-link",
1484
+ ]
1485
+
1486
+ [[package]]
1487
+ name = "windows-strings"
1488
+ version = "0.5.1"
1489
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1490
+ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
1491
+ dependencies = [
1492
+ "windows-link",
1493
+ ]
1494
+
1495
+ [[package]]
1496
+ name = "windows-sys"
1497
+ version = "0.61.2"
1498
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1499
+ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1500
+ dependencies = [
1501
+ "windows-link",
1502
+ ]
1503
+
1504
+ [[package]]
1505
+ name = "winnow"
1506
+ version = "1.0.3"
1507
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1508
+ checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
1509
+
1510
+ [[package]]
1511
+ name = "wit-bindgen"
1512
+ version = "0.51.0"
1513
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1514
+ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
1515
+ dependencies = [
1516
+ "wit-bindgen-rust-macro",
1517
+ ]
1518
+
1519
+ [[package]]
1520
+ name = "wit-bindgen"
1521
+ version = "0.57.1"
1522
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1523
+ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
1524
+
1525
+ [[package]]
1526
+ name = "wit-bindgen-core"
1527
+ version = "0.51.0"
1528
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1529
+ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
1530
+ dependencies = [
1531
+ "anyhow",
1532
+ "heck",
1533
+ "wit-parser",
1534
+ ]
1535
+
1536
+ [[package]]
1537
+ name = "wit-bindgen-rust"
1538
+ version = "0.51.0"
1539
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1540
+ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
1541
+ dependencies = [
1542
+ "anyhow",
1543
+ "heck",
1544
+ "indexmap",
1545
+ "prettyplease",
1546
+ "syn",
1547
+ "wasm-metadata",
1548
+ "wit-bindgen-core",
1549
+ "wit-component",
1550
+ ]
1551
+
1552
+ [[package]]
1553
+ name = "wit-bindgen-rust-macro"
1554
+ version = "0.51.0"
1555
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1556
+ checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
1557
+ dependencies = [
1558
+ "anyhow",
1559
+ "prettyplease",
1560
+ "proc-macro2",
1561
+ "quote",
1562
+ "syn",
1563
+ "wit-bindgen-core",
1564
+ "wit-bindgen-rust",
1565
+ ]
1566
+
1567
+ [[package]]
1568
+ name = "wit-component"
1569
+ version = "0.244.0"
1570
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1571
+ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
1572
+ dependencies = [
1573
+ "anyhow",
1574
+ "bitflags",
1575
+ "indexmap",
1576
+ "log",
1577
+ "serde",
1578
+ "serde_derive",
1579
+ "serde_json",
1580
+ "wasm-encoder",
1581
+ "wasm-metadata",
1582
+ "wasmparser",
1583
+ "wit-parser",
1584
+ ]
1585
+
1586
+ [[package]]
1587
+ name = "wit-parser"
1588
+ version = "0.244.0"
1589
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1590
+ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
1591
+ dependencies = [
1592
+ "anyhow",
1593
+ "id-arena",
1594
+ "indexmap",
1595
+ "log",
1596
+ "semver",
1597
+ "serde",
1598
+ "serde_derive",
1599
+ "serde_json",
1600
+ "unicode-xid",
1601
+ "wasmparser",
1602
+ ]
1603
+
1604
+ [[package]]
1605
+ name = "zerocopy"
1606
+ version = "0.8.52"
1607
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1608
+ checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
1609
+ dependencies = [
1610
+ "zerocopy-derive",
1611
+ ]
1612
+
1613
+ [[package]]
1614
+ name = "zerocopy-derive"
1615
+ version = "0.8.52"
1616
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1617
+ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
1618
+ dependencies = [
1619
+ "proc-macro2",
1620
+ "quote",
1621
+ "syn",
1622
+ ]
1623
+
1624
+ [[package]]
1625
+ name = "zmij"
1626
+ version = "1.0.21"
1627
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1628
+ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "solverforge-hospital"
3
+ version = "2.0.6"
4
+ edition = "2021"
5
+ rust-version = "1.95"
6
+ description = "SolverForge hospital scheduling example"
7
+ publish = false
8
+
9
+ [dependencies]
10
+ solverforge = { version = "0.19.3", features = [
11
+ "serde",
12
+ "console",
13
+ "verbose-logging",
14
+ ] }
15
+ solverforge-ui = "0.6.5"
16
+ rand = "0.10.1"
17
+
18
+ axum = "0.8.9"
19
+ tokio = { version = "1.52.3", features = ["full"] }
20
+ tokio-stream = { version = "0.1.18", features = ["sync"] }
21
+ tower-http = { version = "0.6.10", features = ["fs", "cors"] }
22
+ tower = "0.5.3"
23
+ serde = { version = "1.0.228", features = ["derive"] }
24
+ serde_json = "1.0.149"
25
+ chrono = { version = "0.4.44", features = ["serde"] }
26
+ parking_lot = "0.12.5"
27
+
28
+ [profile.release]
29
+ opt-level = 3
30
+ lto = true
31
+ codegen-units = 1
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build for solverforge-hospital.
2
+
3
+ FROM rust:1.95-alpine AS builder
4
+
5
+ # Install build dependencies
6
+ RUN apk add --no-cache musl-dev
7
+
8
+ WORKDIR /build
9
+
10
+ COPY Cargo.toml Cargo.lock ./
11
+ COPY src/ ./src/
12
+ COPY static/ ./static/
13
+ COPY solver.toml ./solver.toml
14
+
15
+ # Build release binary with musl target for static linking
16
+ RUN cargo build --release --target x86_64-unknown-linux-musl
17
+
18
+ # Runtime stage - minimal Alpine image
19
+ FROM alpine:latest
20
+
21
+ RUN apk add --no-cache ca-certificates
22
+
23
+ WORKDIR /app
24
+
25
+ # Copy binary from builder (musl static binary)
26
+ COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/solverforge-hospital ./solverforge-hospital
27
+
28
+ # Copy static files
29
+ COPY --from=builder /build/static/ ./static/
30
+
31
+ # Copy solver config
32
+ COPY --from=builder /build/solver.toml ./solver.toml
33
+
34
+ ENV PORT=7860
35
+
36
+ # Expose the same port the container binds to by default.
37
+ EXPOSE 7860
38
+
39
+ # Run the application
40
+ CMD ["./solverforge-hospital"]
Makefile ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolverForge Hospital Makefile
2
+ # Rust + frontend + Space-oriented local build system
3
+ #
4
+ # This app is primarily validated for local development and Docker-based
5
+ # Hugging Face Space deployment. `ci-local` therefore simulates the checks we
6
+ # expect before updating a Space, rather than mirroring a GitHub Actions file.
7
+
8
+ SHELL := /bin/sh
9
+ .SHELLFLAGS := -eu -c
10
+ unexport BASH_FUNC_mc%%
11
+
12
+ # ============== Colors & Symbols ==============
13
+ GREEN := \033[92m
14
+ EMERALD := \033[38;2;16;185;129m
15
+ CYAN := \033[96m
16
+ YELLOW := \033[93m
17
+ MAGENTA := \033[95m
18
+ RED := \033[91m
19
+ GRAY := \033[90m
20
+ BOLD := \033[1m
21
+ RESET := \033[0m
22
+
23
+ CHECK := ✓
24
+ CROSS := ✗
25
+ ARROW := ▸
26
+ PROGRESS := →
27
+
28
+ # ============== Project Metadata ==============
29
+ APP_NAME := solverforge-hospital
30
+ PACKAGE_NAME := solverforge-hospital
31
+ VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
32
+ RELEASE_TAG := $(PACKAGE_NAME)@$(VERSION)
33
+ RUST_VERSION := 1.95+
34
+ PORT ?= 7860
35
+ DOCKER_IMAGE ?= $(APP_NAME)
36
+ DOCKER_CONTEXT ?= .
37
+ DOCKERFILE_PATH := Dockerfile
38
+ PLAYWRIGHT ?= ../node_modules/.bin/playwright
39
+
40
+ # ============== Phony Targets ==============
41
+ .PHONY: banner help doctor build build-release run run-release test test-rust \
42
+ test-frontend-syntax test-frontend test-e2e test-slow test-one lint fmt fmt-check \
43
+ clippy check ci-local space-ci space-build space-run docker-build \
44
+ docker-run pre-release release-ci release-info version clean watch require-node require-docker \
45
+
46
+ # ============== Default Target ==============
47
+ .DEFAULT_GOAL := help
48
+
49
+ # ============== Banner ==============
50
+ banner:
51
+ @printf "$(EMERALD)$(BOLD) ____ _ _____\n"
52
+ @printf " / ___| ___ | |_ _____ _ __| ___|__ _ __ __ _ ___\n"
53
+ @printf " \\___ \\\\ / _ \\\\| \\\\ \\\\ / / _ \\\\ '__| |_ / _ \\\\| '__/ _\` |/ _ \\\\\n"
54
+ @printf " ___) | (_) | |\\\\ V / __/ | | _| (_) | | | (_| | __/\n"
55
+ @printf " |____/ \\\\___/|_| \\_/ \\___|_| |_| \\___/|_| \\__, |\\___|\n"
56
+ @printf " |___/$(RESET)\n"
57
+ @printf " $(GRAY)v$(VERSION)$(RESET) $(EMERALD)Hospital demo build system$(RESET)\n\n"
58
+
59
+ # ============== Environment Checks ==============
60
+
61
+ require-node:
62
+ @command -v node >/dev/null 2>&1 || (printf "$(RED)$(CROSS) node is required for frontend validation$(RESET)\n" && exit 1)
63
+
64
+ require-docker:
65
+ @command -v docker >/dev/null 2>&1 || (printf "$(RED)$(CROSS) docker is required for Space/Docker targets$(RESET)\n" && exit 1)
66
+
67
+ doctor: banner
68
+ @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n"
69
+ @printf "$(CYAN)$(BOLD)║ Environment Check ║$(RESET)\n"
70
+ @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n"
71
+ @missing=0; \
72
+ if command -v cargo >/dev/null 2>&1; then \
73
+ printf "$(GREEN)$(CHECK) cargo: $$(cargo --version)$(RESET)\n"; \
74
+ else \
75
+ printf "$(RED)$(CROSS) cargo not found$(RESET)\n"; \
76
+ missing=1; \
77
+ fi; \
78
+ if command -v rustc >/dev/null 2>&1; then \
79
+ printf "$(GREEN)$(CHECK) rustc: $$(rustc --version)$(RESET)\n"; \
80
+ else \
81
+ printf "$(RED)$(CROSS) rustc not found$(RESET)\n"; \
82
+ missing=1; \
83
+ fi; \
84
+ if command -v node >/dev/null 2>&1; then \
85
+ printf "$(GREEN)$(CHECK) node: $$(node --version)$(RESET)\n"; \
86
+ else \
87
+ printf "$(RED)$(CROSS) node not found$(RESET)\n"; \
88
+ missing=1; \
89
+ fi; \
90
+ if command -v docker >/dev/null 2>&1; then \
91
+ printf "$(GREEN)$(CHECK) docker: $$(docker --version)$(RESET)\n"; \
92
+ else \
93
+ printf "$(YELLOW)! docker not found; Space/Docker targets will be unavailable$(RESET)\n"; \
94
+ fi; \
95
+ printf "$(GRAY)Docker build context: $(DOCKER_CONTEXT)$(RESET)\n"; \
96
+ printf "$(GRAY)Default app port: $(PORT)$(RESET)\n"; \
97
+ if [ $$missing -ne 0 ]; then exit 1; fi
98
+ @printf "\n"
99
+
100
+ # ============== Build & Run ==============
101
+
102
+ build: banner
103
+ @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n"
104
+ @printf "$(CYAN)$(BOLD)║ Debug Build ║$(RESET)\n"
105
+ @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n"
106
+ @printf "$(ARROW) $(BOLD)Building $(APP_NAME)...$(RESET)\n"
107
+ @cargo build --bin $(APP_NAME) && \
108
+ printf "$(GREEN)$(CHECK) Debug build successful$(RESET)\n\n" || \
109
+ (printf "$(RED)$(CROSS) Debug build failed$(RESET)\n\n" && exit 1)
110
+
111
+ build-release: banner
112
+ @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n"
113
+ @printf "$(CYAN)$(BOLD)║ Release Build ║$(RESET)\n"
114
+ @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n"
115
+ @printf "$(ARROW) $(BOLD)Building release binary...$(RESET)\n"
116
+ @cargo build --release --bin $(APP_NAME) && \
117
+ printf "$(GREEN)$(CHECK) Release build successful$(RESET)\n\n" || \
118
+ (printf "$(RED)$(CROSS) Release build failed$(RESET)\n\n" && exit 1)
119
+
120
+ run:
121
+ @printf "$(ARROW) Running $(APP_NAME) on port $(PORT)...\n"
122
+ @PORT=$(PORT) cargo run --bin $(APP_NAME)
123
+
124
+ run-release:
125
+ @printf "$(ARROW) Running release build on port $(PORT)...\n"
126
+ @PORT=$(PORT) cargo run --release --bin $(APP_NAME)
127
+
128
+ # ============== Test Targets ==============
129
+
130
+ test: test-rust test-frontend test-e2e
131
+ @printf "\n$(GREEN)$(BOLD)$(CHECK) Standard validation passed$(RESET)\n\n"
132
+
133
+ test-rust: banner
134
+ @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n"
135
+ @printf "$(CYAN)$(BOLD)║ Rust Test Suite ║$(RESET)\n"
136
+ @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n"
137
+ @printf "$(ARROW) $(BOLD)Running cargo test --quiet...$(RESET)\n"
138
+ @cargo test --quiet && \
139
+ printf "\n$(GREEN)$(CHECK) Rust tests passed$(RESET)\n\n" || \
140
+ (printf "\n$(RED)$(CROSS) Rust tests failed$(RESET)\n\n" && exit 1)
141
+
142
+ test-frontend-syntax: require-node
143
+ @printf "$(PROGRESS) Checking frontend module syntax...\n"
144
+ @find static/app -name '*.mjs' -print0 | xargs -0 -n1 node --check && \
145
+ printf "$(GREEN)$(CHECK) Frontend syntax checks passed$(RESET)\n" || \
146
+ (printf "$(RED)$(CROSS) Frontend syntax checks failed$(RESET)\n" && exit 1)
147
+
148
+ test-frontend: test-frontend-syntax
149
+ @printf "$(PROGRESS) Running frontend tests...\n"
150
+ @node --test tests/frontend/*.test.js && \
151
+ printf "$(GREEN)$(CHECK) Frontend tests passed$(RESET)\n" || \
152
+ (printf "$(RED)$(CROSS) Frontend tests failed$(RESET)\n" && exit 1)
153
+
154
+ test-e2e: build-release require-node
155
+ @printf "$(PROGRESS) Running Playwright browser tests...\n"
156
+ @$(PLAYWRIGHT) test --config tests/e2e/playwright.config.js && \
157
+ printf "$(GREEN)$(CHECK) Playwright browser tests passed$(RESET)\n" || \
158
+ (printf "$(RED)$(CROSS) Playwright browser tests failed$(RESET)\n" && exit 1)
159
+
160
+ test-slow: banner
161
+ @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n"
162
+ @printf "$(CYAN)$(BOLD)║ Slow Acceptance Solve ║$(RESET)\n"
163
+ @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n"
164
+ @printf "$(ARROW) $(BOLD)Running large demo acceptance solve...$(RESET)\n"
165
+ @cargo test large_demo_solves_to_feasible_terminal_state -- --ignored --nocapture && \
166
+ printf "\n$(GREEN)$(CHECK) Slow acceptance solve passed$(RESET)\n\n" || \
167
+ (printf "\n$(RED)$(CROSS) Slow acceptance solve failed$(RESET)\n\n" && exit 1)
168
+
169
+ test-one:
170
+ @printf "$(PROGRESS) Running test: $(YELLOW)$(TEST)$(RESET)\n"
171
+ @RUST_LOG=info cargo test $(TEST) -- --nocapture
172
+
173
+ # ============== Lint & Format ==============
174
+
175
+ fmt:
176
+ @printf "$(PROGRESS) Formatting code...\n"
177
+ @find src tests -name '*.rs' -print0 | xargs -0 rustfmt --edition 2021
178
+ @printf "$(GREEN)$(CHECK) Code formatted$(RESET)\n"
179
+
180
+ fmt-check:
181
+ @printf "$(PROGRESS) Checking formatting...\n"
182
+ @find src tests -name '*.rs' -print0 | xargs -0 rustfmt --edition 2021 --check && \
183
+ printf "$(GREEN)$(CHECK) Formatting valid$(RESET)\n" || \
184
+ (printf "$(RED)$(CROSS) Formatting issues found$(RESET)\n" && exit 1)
185
+
186
+ clippy:
187
+ @printf "$(PROGRESS) Running clippy...\n"
188
+ @cargo clippy --all-targets -- -D warnings && \
189
+ printf "$(GREEN)$(CHECK) Clippy passed$(RESET)\n" || \
190
+ (printf "$(RED)$(CROSS) Clippy warnings found$(RESET)\n" && exit 1)
191
+
192
+ lint: fmt-check clippy test-frontend-syntax
193
+ @printf "\n$(GREEN)$(BOLD)$(CHECK) Lint checks passed$(RESET)\n\n"
194
+
195
+ check: lint test
196
+
197
+ # ============== Space & Docker ==============
198
+
199
+ docker-build: require-docker
200
+ @printf "$(PROGRESS) Building Docker image $(DOCKER_IMAGE)...\n"
201
+ @docker build -f "$(DOCKERFILE_PATH)" -t "$(DOCKER_IMAGE)" "$(DOCKER_CONTEXT)" && \
202
+ printf "$(GREEN)$(CHECK) Docker image built$(RESET)\n" || \
203
+ (printf "$(RED)$(CROSS) Docker build failed$(RESET)\n" && exit 1)
204
+
205
+ docker-run: require-docker
206
+ @printf "$(ARROW) Running $(DOCKER_IMAGE) on port $(PORT)...\n"
207
+ @docker run --rm -it -e PORT=$(PORT) -p $(PORT):$(PORT) "$(DOCKER_IMAGE)"
208
+
209
+ space-build: docker-build
210
+
211
+ space-run: space-build
212
+ @printf "$(GREEN)$(CHECK) Starting local container that mirrors the Space image$(RESET)\n"
213
+ @$(MAKE) docker-run --no-print-directory PORT=$(PORT) DOCKER_IMAGE=$(DOCKER_IMAGE)
214
+
215
+ space-ci: ci-local
216
+
217
+ # ============== CI & Release Validation ==============
218
+
219
+ ci-local: banner
220
+ @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════════════════════════╗$(RESET)\n"
221
+ @printf "$(CYAN)$(BOLD)║ Local Space Validation Pipeline ║$(RESET)\n"
222
+ @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════════════════════════╝$(RESET)\n\n"
223
+ @printf "$(ARROW) $(BOLD)Simulating the checks we want green before a Space update...$(RESET)\n\n"
224
+ @printf "$(PROGRESS) Step 1/5: Format check...\n"
225
+ @$(MAKE) fmt-check --no-print-directory
226
+ @printf "$(PROGRESS) Step 2/5: Clippy...\n"
227
+ @$(MAKE) clippy --no-print-directory
228
+ @printf "$(PROGRESS) Step 3/5: Release build...\n"
229
+ @$(MAKE) build-release --no-print-directory
230
+ @printf "$(PROGRESS) Step 4/5: Standard test surface...\n"
231
+ @$(MAKE) test --no-print-directory
232
+ @printf "$(PROGRESS) Step 5/5: Docker/Space image build...\n"
233
+ @$(MAKE) space-build --no-print-directory
234
+ @printf "\n$(GREEN)$(BOLD)╔══════════════════════════════════════════════════════════╗$(RESET)\n"
235
+ @printf "$(GREEN)$(BOLD)║ $(CHECK) SPACE VALIDATION PASSED ║$(RESET)\n"
236
+ @printf "$(GREEN)$(BOLD)╚══════════════════════════════════════════════════════════╝$(RESET)\n\n"
237
+
238
+ pre-release: banner
239
+ @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════════════════════════╗$(RESET)\n"
240
+ @printf "$(CYAN)$(BOLD)║ Pre-Release Validation v$(VERSION) ║$(RESET)\n"
241
+ @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════════════════════════╝$(RESET)\n\n"
242
+ @$(MAKE) ci-local --no-print-directory
243
+ @printf "$(PROGRESS) Final step: slow acceptance solve...\n"
244
+ @$(MAKE) test-slow --no-print-directory
245
+ @printf "$(GREEN)$(BOLD)$(CHECK) Ready for a Space update$(RESET)\n\n"
246
+
247
+ release-ci: ci-local
248
+ @printf "$(GREEN)$(BOLD)$(CHECK) Release CI passed for $(RELEASE_TAG)$(RESET)\n\n"
249
+
250
+ release-info:
251
+ @printf "$(CYAN)Package:$(RESET) $(YELLOW)$(BOLD)$(PACKAGE_NAME)$(RESET)\n"
252
+ @printf "$(CYAN)Version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n"
253
+ @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n"
254
+
255
+ # ============== Metadata & Cleanup ==============
256
+
257
+ version:
258
+ @printf "$(CYAN)Current version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n"
259
+ @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n"
260
+ @printf "$(CYAN)Default port:$(RESET) $(YELLOW)$(BOLD)$(PORT)$(RESET)\n"
261
+
262
+ clean:
263
+ @printf "$(ARROW) Cleaning build artifacts...\n"
264
+ @cargo clean
265
+ @printf "$(GREEN)$(CHECK) Clean complete$(RESET)\n"
266
+
267
+ watch:
268
+ @printf "$(ARROW) Watching and rerunning the app on port $(PORT)...\n"
269
+ @cargo watch --version >/dev/null 2>&1 || \
270
+ (printf "$(RED)$(CROSS) cargo-watch is required for make watch$(RESET)\n" && exit 1)
271
+ @cargo watch -x "run --bin $(APP_NAME)"
272
+
273
+ # ============== Help ==============
274
+
275
+ help: banner
276
+ @/bin/echo -e "$(CYAN)$(BOLD)Environment:$(RESET)"
277
+ @/bin/echo -e " $(GREEN)make doctor$(RESET) - Check toolchain and Docker readiness"
278
+ @/bin/echo -e ""
279
+ @/bin/echo -e "$(CYAN)$(BOLD)Build & Run:$(RESET)"
280
+ @/bin/echo -e " $(GREEN)make build$(RESET) - Build the app in debug mode"
281
+ @/bin/echo -e " $(GREEN)make build-release$(RESET) - Build the app in release mode"
282
+ @/bin/echo -e " $(GREEN)make run$(RESET) - Run the app locally on port $(PORT)"
283
+ @/bin/echo -e " $(GREEN)make run-release$(RESET) - Run the release build locally on port $(PORT)"
284
+ @/bin/echo -e ""
285
+ @/bin/echo -e "$(CYAN)$(BOLD)Tests & Validation:$(RESET)"
286
+ @/bin/echo -e " $(GREEN)make test$(RESET) - Run the standard Rust, frontend, and Playwright test surface"
287
+ @/bin/echo -e " $(GREEN)make test-rust$(RESET) - Run Rust tests only"
288
+ @/bin/echo -e " $(GREEN)make test-frontend$(RESET) - Run frontend syntax checks and tests"
289
+ @/bin/echo -e " $(GREEN)make test-e2e$(RESET) - Run Playwright browser tests"
290
+ @/bin/echo -e " $(GREEN)make test-slow$(RESET) - Run the ignored large-demo acceptance solve"
291
+ @/bin/echo -e " $(GREEN)make test-one TEST=name$(RESET) - Run a specific Rust test with output"
292
+ @/bin/echo -e " $(GREEN)make lint$(RESET) - Run fmt-check, clippy, and frontend syntax checks"
293
+ @/bin/echo -e " $(GREEN)make check$(RESET) - Run lint plus the standard test surface"
294
+ @/bin/echo -e " $(GREEN)make release-ci$(RESET) - Run the tag-publish CI gate for this app"
295
+ @/bin/echo -e ""
296
+ @/bin/echo -e "$(CYAN)$(BOLD)Space & Docker:$(RESET)"
297
+ @/bin/echo -e " $(GREEN)make space-build$(RESET) - Build the Docker image used for Space-style deployment"
298
+ @/bin/echo -e " $(GREEN)make space-run$(RESET) - Build and run that image locally on port $(PORT)"
299
+ @/bin/echo -e " $(GREEN)make ci-local$(RESET) - Simulate the pre-push validation for a Hugging Face Space"
300
+ @/bin/echo -e " $(GREEN)make pre-release$(RESET) - Run ci-local plus the slow acceptance solve"
301
+ @/bin/echo -e ""
302
+ @/bin/echo -e "$(CYAN)$(BOLD)Other:$(RESET)"
303
+ @/bin/echo -e " $(GREEN)make fmt$(RESET) - Format Rust code"
304
+ @/bin/echo -e " $(GREEN)make release-info$(RESET) - Show package version and app-scoped release tag"
305
+ @/bin/echo -e " $(GREEN)make version$(RESET) - Show version and default port"
306
+ @/bin/echo -e " $(GREEN)make clean$(RESET) - Clean build artifacts"
307
+ @/bin/echo -e " $(GREEN)make watch$(RESET) - Watch source files and rerun the app"
308
+ @/bin/echo -e " $(GREEN)make help$(RESET) - Show this help message"
309
+ @/bin/echo -e ""
310
+ @/bin/echo -e "$(GRAY)Rust version required: $(RUST_VERSION)$(RESET)"
311
+ @/bin/echo -e "$(GRAY)Current version: v$(VERSION)$(RESET)"
312
+ @/bin/echo -e "$(GRAY)Release tag: $(RELEASE_TAG)$(RESET)"
313
+ @/bin/echo -e "$(GRAY)Default port: $(PORT)$(RESET)"
314
+ @/bin/echo -e ""
README.md ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: SolverForge Hospital
3
+ emoji: 🏥
4
+ colorFrom: red
5
+ colorTo: pink
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: apache-2.0
10
+ short_description: SolverForge hospital scheduling example
11
+ ---
12
+
13
+ # SolverForge Hospital
14
+
15
+ ![SolverForge Hospital screenshot](docs/screenshot.png)
16
+
17
+ `solverforge-hospital` is a SolverForge employee-scheduling app with retained
18
+ jobs, schedule analysis, and a browser timeline workspace.
19
+
20
+ It answers one concrete question:
21
+
22
+ "Given a hospital workforce and a month of shifts, which employee should cover
23
+ each shift?"
24
+
25
+ ## Quick Start
26
+
27
+ ```sh
28
+ make run-release
29
+ ```
30
+
31
+ Then open `http://localhost:7860`.
32
+
33
+ To inspect the supported command surface:
34
+
35
+ ```sh
36
+ make help
37
+ ```
38
+
39
+ ## Documentation Map
40
+
41
+ - `README.md`
42
+ Quick start, model concepts, validation, REST API, and solver policy.
43
+ - `WIREFRAME.md`
44
+ As-built architecture and runtime/data flow across backend, runtime, and UI.
45
+ - `docs/api-and-solver-policy.md`
46
+ Detailed route, payload, lifecycle, telemetry, and solver-policy reference.
47
+ - `AGENTS.md`
48
+ Codex-facing maintenance, validation, and documentation rules.
49
+ - `Makefile`
50
+ Supported local commands for development, validation, Docker, and Space work.
51
+ - `Dockerfile`
52
+ Docker Space image build using Rust 1.95 and the declared crates.io line.
53
+
54
+ ## Current Dependency Shape
55
+
56
+ - Package: `solverforge-hospital`; version is declared in `Cargo.toml`
57
+ - Release binary: `solverforge-hospital`
58
+ - Rust: `1.95`
59
+ - SolverForge runtime: `solverforge` `0.19.3`
60
+ - Browser UI assets: `solverforge-ui` `0.6.5`
61
+ - Scaffold metadata: `solverforge-cli` `2.2.2` in `solverforge.app.toml`
62
+
63
+ The app serves registry-backed Rust dependencies, local static browser modules,
64
+ and Axum API routes from one process.
65
+
66
+ ## Model Concepts
67
+
68
+ - `Employee` is a problem fact: input staff data the solver reads but does not
69
+ move.
70
+ - `Shift` is the planning entity: each shift needs exactly one employee.
71
+ - `Shift.employee_idx` is the scalar planning variable: the employee index
72
+ SolverForge changes during construction and local search.
73
+ - `CareHub` is a derived domain grouping that makes nearby search prefer
74
+ employees close to the service line.
75
+ - `Plan` is the planning solution with the current `HardSoftDecimalScore`.
76
+
77
+ The app ships one deterministic `LARGE` dataset with a 28-day horizon, 50
78
+ employees, and 688 shifts.
79
+
80
+ ## Constraints
81
+
82
+ Hard constraints:
83
+
84
+ - Every shift is assigned.
85
+ - The assigned employee has the required skill.
86
+ - An employee is not assigned to overlapping shifts.
87
+ - An employee has at least 10 hours between two shifts.
88
+ - An employee works at most one shift per day.
89
+ - Unavailable employees are not assigned.
90
+
91
+ Soft constraints:
92
+
93
+ - Undesired days are avoided.
94
+ - Desired days are rewarded.
95
+ - Assignments are balanced across employees.
96
+
97
+ ## REST API
98
+
99
+ - `GET /health`
100
+ - `GET /info`
101
+ - `GET /demo-data`
102
+ - `GET /demo-data/{id}`
103
+ - `POST /jobs`
104
+ - `GET /jobs/{id}`
105
+ - `DELETE /jobs/{id}`
106
+ - `GET /jobs/{id}/status`
107
+ - `GET /jobs/{id}/snapshot`
108
+ - `GET /jobs/{id}/analysis`
109
+ - `POST /jobs/{id}/pause`
110
+ - `POST /jobs/{id}/resume`
111
+ - `POST /jobs/{id}/cancel`
112
+ - `GET /jobs/{id}/events`
113
+
114
+ `snapshot_revision={n}` is optional for snapshots and analysis. SSE clients
115
+ receive a bootstrap event and then live retained-job events. The browser also
116
+ exposes a visible REST API guide expected to match
117
+ `docs/api-and-solver-policy.md`.
118
+
119
+ ## Solver Policy
120
+
121
+ `solver.toml` is embedded by `Plan` and is the runtime source of truth.
122
+
123
+ - `cheapest_insertion` assigns employee indexes during construction.
124
+ - Local search uses nearby change and nearby swap moves over
125
+ `Shift.employee_idx`.
126
+ - Nearby search reads the app's care-hub distance signals so moves stay focused
127
+ on plausible employee/shift pairs.
128
+ - `late_acceptance` with an accepted-count forager keeps several candidate
129
+ moves alive per step.
130
+ - Solving stops after 30 seconds total or after 5 seconds without improvement.
131
+
132
+ The hidden witness roster in `src/data/data_seed/witness.rs` shapes a
133
+ hard-feasible public instance, but the solver never receives the witness itself.
134
+
135
+ ## Validation
136
+
137
+ Standard validation:
138
+
139
+ ```sh
140
+ make test
141
+ ```
142
+
143
+ Full local validation:
144
+
145
+ ```sh
146
+ make ci-local
147
+ ```
148
+
149
+ Slow acceptance solve:
150
+
151
+ ```sh
152
+ make test-slow
153
+ ```
154
+
155
+ `make test` runs Rust tests, browserless frontend tests, and Playwright browser
156
+ tests. `make ci-local` adds formatting, clippy, release build, and Docker image
157
+ build. `make pre-release` runs `ci-local` plus the slow acceptance solve.
158
+
159
+ ## Hugging Face Space Deployment
160
+
161
+ This repo is Docker-Space ready. The Space reads the README front matter,
162
+ builds `Dockerfile`, and expects the app to bind `PORT=7860`.
163
+
164
+ Local Space-equivalent commands:
165
+
166
+ ```sh
167
+ make space-build
168
+ make space-run
169
+ ```
170
+
171
+ ## Read The Code In This Order
172
+
173
+ 1. `src/domain/employee.rs`
174
+ The staff problem fact model.
175
+ 2. `src/domain/care_hub.rs`
176
+ Service-line grouping for nearby search.
177
+ 3. `src/domain/mod.rs`
178
+ The `planning_model!` manifest and public domain exports.
179
+ 4. `src/domain/plan.rs`
180
+ The `Shift` planning entity, `Plan` solution, derived fields, and nearby
181
+ meters.
182
+ 5. `src/constraints/mod.rs` and `src/constraints/*.rs`
183
+ The score model, one scheduling rule per file.
184
+ 6. `src/data/data_seed/entrypoints.rs`
185
+ Public demo-data IDs.
186
+ 7. `src/data/data_seed/large.rs` and `src/data/data_seed/witness.rs`
187
+ The published instance builder and hidden feasibility witness.
188
+ 8. `src/solver/service.rs`
189
+ Retained-job orchestration over `SolverManager<Plan>`.
190
+ 9. `src/api/routes.rs`, `src/api/dto.rs`, and `src/api/sse.rs`
191
+ HTTP routes, transport DTOs, and live-event streaming.
192
+ 10. `static/app/main.mjs`, `static/app/shell/`, and `static/app/schedule/`
193
+ Browser boot sequence, shell, and timeline views.
194
+
195
+ ## Project Shape
196
+
197
+ - `src/domain/`
198
+ Planning model, domain types, derived fields, and nearby meters.
199
+ - `src/constraints/`
200
+ Incremental SolverForge scoring rules.
201
+ - `src/data/`
202
+ Deterministic hospital demo-data generator.
203
+ - `src/solver/`
204
+ Retained-job facade and runtime event payload formatting.
205
+ - `src/api/`
206
+ Axum routes, DTOs, and SSE endpoint.
207
+ - `static/app/`
208
+ Browser modules built on stock `solverforge-ui` assets.
209
+ - `tests/frontend/`
210
+ Browserless UI tests using the fake DOM in `tests/support/`.
211
+ - `tests/e2e/`
212
+ Playwright browser tests for the served app.
WIREFRAME.md ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # solverforge-hospital WIREFRAME
2
+
3
+ This file is the architectural map for beginners.
4
+
5
+ If `README.md` tells you how to run the app, this document tells you how the
6
+ pieces fit together and in which order to read them.
7
+
8
+ ## Documentation Roles
9
+
10
+ The docs in this repo are meant to work together rather than compete:
11
+
12
+ - `README.md`
13
+ Quick start, concepts, and user-facing orientation.
14
+ - `WIREFRAME.md`
15
+ Architecture, execution flow, and file-map walkthrough.
16
+ - `docs/api-and-solver-policy.md`
17
+ REST routes, lifecycle semantics, payload shape, and solver policy notes.
18
+ - `AGENTS.md`
19
+ Rules for keeping code, comments, tests, and docs aligned in future changes.
20
+ - `Makefile`
21
+ The shared developer command surface, including the local Space validation
22
+ pipeline.
23
+ - `docs/screenshot.png`
24
+ Current browser screenshot embedded by the README.
25
+
26
+ ## What This Repo Is Teaching
27
+
28
+ `solverforge-hospital` is a complete SolverForge example, not just a scoring
29
+ snippet.
30
+
31
+ It shows how to build a planning app where:
32
+
33
+ - the domain model is small and explicit
34
+ - the score rules are readable one file at a time
35
+ - the dataset is deterministic and intentionally shaped
36
+ - the solver runs as a retained background job
37
+ - the browser UI watches the solve through REST and SSE
38
+
39
+ The planning question is:
40
+
41
+ "For each hospital shift, which employee should be assigned?"
42
+
43
+ ## SolverForge Concepts In Plain Language
44
+
45
+ - `Employee`
46
+ Input data. The solver reads it, but does not move it.
47
+ - `Shift`
48
+ The thing the solver is allowed to assign.
49
+ - `employee_idx`
50
+ The one real decision variable in this app. It points from a shift to one
51
+ employee inside `Plan.employees`.
52
+ - hard score
53
+ Rules that must not be broken, such as missing skills or overlapping shifts.
54
+ - soft score
55
+ Preferences and quality goals, such as honoring desired days or balancing
56
+ workload.
57
+ - retained job
58
+ A solve that keeps living in memory after it starts, so the UI can poll it,
59
+ pause it, resume it, stop it through runtime cancel, or inspect snapshots.
60
+ Delete is terminal cleanup before the next fresh Solve, not the Stop action.
61
+
62
+ ## Read Order
63
+
64
+ If you are new to this repo, read files in this order:
65
+
66
+ 1. `src/domain/employee.rs`
67
+ Learn the input facts first.
68
+ 2. `src/domain/care_hub.rs`
69
+ Learn the hospital service-line grouping used by nearby search.
70
+ 3. `src/domain/mod.rs`
71
+ See the `planning_model!` manifest that lists and exports the model modules.
72
+ 4. `src/domain/plan.rs`
73
+ Learn the planning entity, planning variable, nearby meters, and derived
74
+ fields.
75
+ 5. `src/constraints/mod.rs`
76
+ See the full score model at a glance.
77
+ 6. `src/constraints/*.rs`
78
+ Read one scheduling rule per file.
79
+ 7. `src/data/data_seed/entrypoints.rs`
80
+ See the public demo-data surface.
81
+ 8. `src/data/data_seed/large.rs`
82
+ See how the published dataset is assembled.
83
+ 9. `src/solver/service.rs`
84
+ See how a domain solve becomes a retained runtime job.
85
+ 10. `src/api/routes.rs` and `src/api/sse.rs`
86
+ See the HTTP contract.
87
+ 11. `static/app/main.mjs`
88
+ See the browser boot sequence.
89
+ 12. `static/app/shell/` and `static/app/schedule/`
90
+ See how stock `solverforge-ui` components are adapted to this hospital demo.
91
+
92
+ ## Runtime Flow
93
+
94
+ The shortest way to understand the app is to follow one request all the way
95
+ through:
96
+
97
+ 1. The browser loads `static/index.html`.
98
+ 2. `static/app/main.mjs` loads config and the generated UI model, validates the
99
+ configured `LARGE` id through `/demo-data`, and then fetches
100
+ `/demo-data/LARGE`.
101
+ 3. The frontend turns the returned `PlanDto` into schedule rails and side-panel
102
+ summaries.
103
+ 4. When the user clicks Solve, the frontend sends the current plan to
104
+ `POST /jobs`.
105
+ 5. `src/api/routes.rs` converts that HTTP request into a `PlanDto`.
106
+ 6. `PlanDto::to_domain()` rebuilds the in-memory `Plan`, including derived
107
+ helper fields the solver expects.
108
+ 7. `SolverService` starts a retained solve through `SolverManager<Plan>`.
109
+ 8. The solver emits lifecycle events carrying telemetry and best-solution
110
+ snapshots.
111
+ 9. `src/solver/service.rs` coordinates the event stream, while
112
+ `src/solver/service/payload.rs` converts runtime events into the JSON shapes
113
+ expected by the UI.
114
+ 10. The browser consumes those events over `/jobs/{id}/events` and updates the
115
+ visible status and timeline; analysis is fetched from
116
+ `/jobs/{id}/analysis` when requested.
117
+
118
+ The browser shell also contains a visible REST API guide. That makes
119
+ `static/app/shell/api-guide.mjs` part of the documentation surface, not just a
120
+ UI helper.
121
+
122
+ ## File Map
123
+
124
+ ```text
125
+ .
126
+ ├── Cargo.toml
127
+ │ Rust crate metadata and registry dependency requests.
128
+ ├── solver.toml
129
+ │ Embedded solver policy. This is the runtime source of truth for search.
130
+ ├── solverforge.app.toml
131
+ │ App metadata, model surface, and the `solverforge 0.19.3` runtime target.
132
+ ├── Dockerfile
133
+ │ Container build for running the app outside the dev checkout.
134
+ ├── Makefile
135
+ │ Local build, validation, and Docker/Space workflow wrapper.
136
+ ├── README.md
137
+ │ Beginner run guide and learning path.
138
+ ├── docs/api-and-solver-policy.md
139
+ │ REST, payload, lifecycle, telemetry, and solver-policy reference.
140
+ ├── WIREFRAME.md
141
+ │ This architectural walkthrough.
142
+ ├── AGENTS.md
143
+ │ Repo-specific contributor and documentation rules.
144
+ ├── docs/screenshot.png
145
+ │ Current browser screenshot used by the README.
146
+ ├── src/
147
+ │ ├── lib.rs
148
+ │ │ Crate root and public module surface.
149
+ │ ├── main.rs
150
+ │ │ Axum server bootstrap, CORS, static serving, and route composition.
151
+ │ ├── domain/
152
+ │ │ `planning_model!` manifest plus problem model modules.
153
+ │ ├── constraints/
154
+ │ │ One scheduling rule per file plus the assembler in `mod.rs`.
155
+ │ ├── data/
156
+ │ │ Deterministic demo-data generator and published entrypoints.
157
+ │ ├── solver/
158
+ │ │ Retained-job facade over the SolverForge runtime.
159
+ │ └── api/
160
+ │ DTOs, REST routes, and SSE streaming.
161
+ ├── static/
162
+ │ ├── index.html
163
+ │ │ Browser entrypoint.
164
+ │ ├── sf-config.json
165
+ │ │ Runtime UI config for the stock frontend shell.
166
+ │ ├── generated/ui-model.json
167
+ │ │ Generated view metadata used by `solverforge-ui`.
168
+ │ └── app/
169
+ │ ├── main.mjs
170
+ │ │ Browser boot and wiring.
171
+ │ ├── shell/
172
+ │ │ App shell, state, solver controls, and panels.
173
+ │ ├── schedule/
174
+ │ │ Hospital-specific grouping, presentation, and rail rendering.
175
+ │ └── views/registry.mjs
176
+ │ Named view registration.
177
+ └── tests/
178
+ ├── frontend/
179
+ │ Browserless frontend tests.
180
+ ├── e2e/
181
+ │ Playwright browser tests for the served app.
182
+ └── support/
183
+ Fake DOM support used by the frontend tests.
184
+ ```
185
+
186
+ ## Why The Model Looks This Way
187
+
188
+ This app is intentionally narrow.
189
+
190
+ - There is one planning entity type: `Shift`.
191
+ - There is one scalar planning variable: `employee_idx`.
192
+ - Nearby search is attached directly to that scalar variable.
193
+ - The solver does not juggle multiple variable types, sequence assignments, or
194
+ list planning.
195
+
196
+ That makes the example easier to learn because the optimization problem stays
197
+ visible:
198
+
199
+ - facts live in `Plan.employees`
200
+ - decisions live in `Plan.shifts[*].employee_idx`
201
+ - score rules read those two things and judge the assignment
202
+
203
+ ## Why The Demo Data Is Structured
204
+
205
+ The demo-data generator is not filler.
206
+
207
+ It is designed to give beginners a problem that is:
208
+
209
+ - deterministic
210
+ - feasible
211
+ - interesting enough that local search still has work to do
212
+ - stable enough that tests and comparisons stay meaningful
213
+
214
+ One important design trick is the hidden witness roster in
215
+ `src/data/data_seed/witness.rs`.
216
+
217
+ That internal roster gives the generator a known feasible staffing pattern.
218
+ The published dataset is then shaped around that witness, while the solver only
219
+ sees the final public problem. This lets the repo ship a realistic-feeling
220
+ demo without random feasibility failures.
221
+
222
+ ## Why The Runtime Uses REST And SSE
223
+
224
+ The solve is long-lived compared with a normal request/response handler, so the
225
+ backend splits the contract into two parts:
226
+
227
+ - REST for control and snapshots
228
+ - SSE for live progress
229
+
230
+ The REST surface is:
231
+
232
+ - `/health` and `/info` expose liveness and app metadata.
233
+ - `/demo-data` and `/demo-data/{id}` expose the deterministic demo catalog.
234
+ - `/jobs` creates a retained solver job.
235
+ - `/jobs/{id}` and `/jobs/{id}/status` expose summary state.
236
+ - `/jobs/{id}/snapshot` returns an exact or latest snapshot.
237
+ - `/jobs/{id}/analysis` runs constraint analysis for a snapshot.
238
+ - `/jobs/{id}/pause`, `/jobs/{id}/resume`, and `/jobs/{id}/cancel` control a
239
+ live job.
240
+ - `DELETE /jobs/{id}` removes a terminal retained job.
241
+ - `/jobs/{id}/events` streams typed lifecycle events.
242
+
243
+ That separation keeps the frontend simple:
244
+
245
+ - create a job
246
+ - poll or fetch details when needed
247
+ - subscribe once to the live event stream
248
+
249
+ It also mirrors how a real retained SolverForge app behaves in production.
250
+
251
+ One small but important detail: `GET /jobs/{id}` and `GET /jobs/{id}/status`
252
+ return the same summary payload. The second route exists as a stock-compatible
253
+ alias for clients that expect the explicit `/status` URL shape.
254
+
255
+ ## Solver Policy
256
+
257
+ `solver.toml` is embedded by `Plan` and is therefore part of the actual model,
258
+ not a side document.
259
+
260
+ The shipped search policy is deliberately conservative:
261
+
262
+ - `cheapest_insertion` builds a feasible first assignment
263
+ - local search stays in the nearby scalar neighborhood
264
+ - `late_acceptance` and `accepted_count` keep the search moving without blowing
265
+ up step cost
266
+
267
+ That narrow configuration is the shipped 30-second policy for this demo.
268
+ `make test-slow` is the acceptance gate for any solver-policy change.
269
+
270
+ ## Frontend Design
271
+
272
+ The frontend is intentionally thin.
273
+
274
+ This repo is not trying to teach a custom framework. It is showing how to take
275
+ stock `solverforge-ui` pieces and adapt them to one concrete planning problem.
276
+
277
+ - `shell/` owns app lifecycle, backend wiring, and side panels
278
+ - `schedule/` owns the hospital-specific transformation from domain data to
279
+ visual rails
280
+ - tests in `tests/frontend/` lock browserless presentation behavior down
281
+ - tests in `tests/e2e/` verify the served browser app with Playwright
282
+
283
+ ## Validation Surfaces
284
+
285
+ When you change this repo, think in six separate layers:
286
+
287
+ 1. Domain and constraint logic:
288
+ `make test-rust`
289
+ 2. Slow end-to-end solve quality:
290
+ `make test-slow`
291
+ 3. Frontend module correctness:
292
+ `make test-frontend-syntax`
293
+ 4. Frontend module behavior:
294
+ `make test-frontend`
295
+ 5. Served browser behavior:
296
+ `make test-e2e`
297
+ 6. Local deploy readiness for the Docker-based Space target:
298
+ `make ci-local`
299
+
300
+ If you keep those six layers green, the repo remains teachable and usable.
docs/api-and-solver-policy.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API And Solver Policy
2
+
3
+ This page holds the longer reference material that must stay aligned with
4
+ `src/api/routes.rs`, `src/api/dto.rs`, `src/solver/service.rs`,
5
+ `src/solver/service/payload.rs`, `solver.toml`, and the visible API guide in
6
+ `static/app/shell/api-guide.mjs`.
7
+
8
+ ## REST API
9
+
10
+ - `GET /health`
11
+ - `GET /info`
12
+ - `GET /demo-data`
13
+ - `GET /demo-data/{id}`
14
+ - `POST /jobs`
15
+ - `GET /jobs/{id}`
16
+ - `GET /jobs/{id}/status`
17
+ - `GET /jobs/{id}/snapshot`
18
+ - `GET /jobs/{id}/analysis`
19
+ - `POST /jobs/{id}/pause`
20
+ - `POST /jobs/{id}/resume`
21
+ - `POST /jobs/{id}/cancel`
22
+ - `DELETE /jobs/{id}`
23
+ - `GET /jobs/{id}/events`
24
+
25
+ ## Lifecycle Semantics
26
+
27
+ - `pause` requests an exact runtime-managed pause and checkpoint
28
+ - `resume` continues from the retained checkpoint
29
+ - the user-facing Stop control calls `cancel` to stop a live or paused job
30
+ - `delete` removes a terminal retained job before the next fresh solve
31
+ - `GET /jobs/{id}` and `GET /jobs/{id}/status` return the same summary payload
32
+ - `snapshot_revision={n}` is optional on both snapshot and analysis requests
33
+ - reconnects bootstrap from current runtime status plus retained snapshot
34
+ revision, not from cached SSE text
35
+
36
+ ## Payload Shape
37
+
38
+ The transport payload mirrors the domain model directly. The important field is
39
+ `employeeIdx`, which is the scalar planning assignment chosen for each shift.
40
+
41
+ ```json
42
+ {
43
+ "employees": [
44
+ {
45
+ "id": "employee-0",
46
+ "name": "Alex Smith",
47
+ "homeHub": "critical_care",
48
+ "skills": ["Critical care doctor"],
49
+ "unavailableDates": [],
50
+ "undesiredDates": [],
51
+ "desiredDates": []
52
+ }
53
+ ],
54
+ "shifts": [
55
+ {
56
+ "id": "shift-1",
57
+ "start": "2024-01-01T08:00:00",
58
+ "end": "2024-01-01T16:00:00",
59
+ "location": "Critical care",
60
+ "careHub": "critical_care",
61
+ "requiredSkill": "Critical care doctor",
62
+ "employeeIdx": 0
63
+ }
64
+ ],
65
+ "score": "0hard/0soft"
66
+ }
67
+ ```
68
+
69
+ Telemetry is intentionally a UI-facing projection, not the raw runtime type.
70
+ Fields like `elapsedMs`, `movesPerSecond`, and `acceptanceRate` are derived for
71
+ display.
72
+
73
+ ## Solver Policy
74
+
75
+ The runtime source of truth is [../solver.toml](../solver.toml).
76
+
77
+ The currently shipped policy is deliberately narrow:
78
+
79
+ - `construction_heuristic = cheapest_insertion`
80
+ - one local-search phase
81
+ - nearby scalar change/swap selectors
82
+ - `late_acceptance`
83
+ - `accepted_count`
84
+
85
+ This is the policy exercised by the standard runtime tests. `make test-slow`
86
+ runs the ignored large-demo acceptance solve and is the required quality gate
87
+ when changing that policy.
88
+
89
+ The canonical description of current behavior is `solver.toml`, the code,
90
+ `README.md`, this file, and `WIREFRAME.md`.
docs/screenshot.png ADDED

Git LFS Details

  • SHA256: 5f7678f6eccd57c55e8f5d550c5ac3614fd3f8325177eeafec620d9273014e4e
  • Pointer size: 131 Bytes
  • Size of remote file: 151 kB
solver.toml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolverForge configuration for the hospital example.
2
+ #
3
+ # This app uses scalar nearby selection directly. The care-hub meters in
4
+ # `src/domain/plan.rs` keep local search focused enough to make real progress
5
+ # on the hospital dataset inside the configured time limit.
6
+
7
+ random_seed = 1
8
+
9
+ [termination]
10
+ seconds_spent_limit = 30
11
+ unimproved_seconds_spent_limit = 5
12
+
13
+ [[phases]]
14
+ type = "construction_heuristic"
15
+ construction_heuristic_type = "cheapest_insertion"
16
+ entity_class = "Shift"
17
+ variable_name = "employee_idx"
18
+
19
+ [[phases]]
20
+ type = "local_search"
21
+
22
+ [phases.acceptor]
23
+ type = "late_acceptance"
24
+ late_acceptance_size = 400
25
+
26
+ [phases.forager]
27
+ type = "accepted_count"
28
+ limit = 4
29
+
30
+ [phases.move_selector]
31
+ type = "union_move_selector"
32
+
33
+ [[phases.move_selector.selectors]]
34
+ type = "nearby_change_move_selector"
35
+ entity_class = "Shift"
36
+ variable_name = "employee_idx"
37
+ max_nearby = 10
38
+
39
+ [[phases.move_selector.selectors]]
40
+ type = "nearby_swap_move_selector"
41
+ entity_class = "Shift"
42
+ variable_name = "employee_idx"
43
+ max_nearby = 10
solverforge.app.toml ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [app]
2
+ name = "SolverForge Hospital"
3
+ starter = "neutral-shell"
4
+ shell = "web"
5
+ cli_version = "2.2.2"
6
+
7
+ [runtime]
8
+ target = "solverforge 0.19.3"
9
+ runtime_source = "crates.io: solverforge 0.19.3"
10
+ ui_source = "crates.io: solverforge-ui 0.6.5"
11
+
12
+ [demo]
13
+ default_size = "LARGE"
14
+ available_sizes = ["LARGE"]
15
+
16
+ [solution]
17
+ name = "Plan"
18
+ score = "HardSoftDecimalScore"
19
+
20
+ [[facts]]
21
+ name = "employee"
22
+ plural = "employees"
23
+ kind = "problem_fact"
24
+
25
+ [[entities]]
26
+ name = "shift"
27
+ plural = "shifts"
28
+ kind = "planning_entity"
29
+
30
+ [[variables]]
31
+ entity = "shift"
32
+ entity_plural = "shifts"
33
+ field = "employee_idx"
34
+ kind = "scalar"
35
+ range = "employees"
36
+ elements = ""
37
+ allows_unassigned = true
38
+ enabled = true
39
+
40
+ [[constraints]]
41
+ name = "assigned_shift"
42
+ module = "assigned_shift"
43
+ enabled = true
44
+
45
+ [[constraints]]
46
+ name = "required_skill"
47
+ module = "required_skill"
48
+ enabled = true
49
+
50
+ [[constraints]]
51
+ name = "overlapping_shift"
52
+ module = "overlapping_shift"
53
+ enabled = true
54
+
55
+ [[constraints]]
56
+ name = "minimum_rest"
57
+ module = "minimum_rest"
58
+ enabled = true
59
+
60
+ [[constraints]]
61
+ name = "one_shift_per_day"
62
+ module = "one_shift_per_day"
63
+ enabled = true
64
+
65
+ [[constraints]]
66
+ name = "unavailable_employee"
67
+ module = "unavailable_employee"
68
+ enabled = true
69
+
70
+ [[constraints]]
71
+ name = "undesired_day"
72
+ module = "undesired_day"
73
+ enabled = true
74
+
75
+ [[constraints]]
76
+ name = "desired_day"
77
+ module = "desired_day"
78
+ enabled = true
79
+
80
+ [[constraints]]
81
+ name = "balance_assignments"
82
+ module = "balance_assignments"
83
+ enabled = true
src/api/dto.rs ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Transport DTOs that turn domain/runtime types into beginner-friendly JSON.
2
+
3
+ use serde::{Deserialize, Serialize};
4
+ use serde_json::{Map, Value};
5
+ use solverforge::{
6
+ HardSoftDecimalScore, ScoreAnalysis, SolverLifecycleState, SolverSnapshot,
7
+ SolverSnapshotAnalysis, SolverStatus, SolverTelemetry, SolverTerminalReason,
8
+ };
9
+ use std::time::Duration;
10
+
11
+ use crate::domain::Plan;
12
+
13
+ /// Thin JSON wrapper around the planning solution.
14
+ ///
15
+ /// We keep the plan fields flattened so the API payload reads like the domain
16
+ /// model instead of like a transport envelope.
17
+ #[derive(Debug, Clone, Serialize, Deserialize)]
18
+ #[serde(rename_all = "camelCase")]
19
+ pub struct PlanDto {
20
+ #[serde(flatten)]
21
+ pub fields: Map<String, Value>,
22
+ #[serde(default)]
23
+ pub score: Option<String>,
24
+ }
25
+
26
+ /// One constraint row shown in the Analyze modal.
27
+ #[derive(Debug, Clone, Serialize)]
28
+ #[serde(rename_all = "camelCase")]
29
+ pub struct ConstraintAnalysisDto {
30
+ pub name: String,
31
+ pub weight: String,
32
+ pub score: String,
33
+ pub match_count: usize,
34
+ }
35
+
36
+ /// Top-level analysis payload returned by `/jobs/{id}/analysis`.
37
+ #[derive(Debug, Clone, Serialize)]
38
+ #[serde(rename_all = "camelCase")]
39
+ pub struct AnalyzeResponse {
40
+ pub score: String,
41
+ pub constraints: Vec<ConstraintAnalysisDto>,
42
+ }
43
+
44
+ /// UI-facing telemetry summary derived from exact runtime telemetry.
45
+ #[derive(Debug, Clone, Copy, Serialize)]
46
+ #[serde(rename_all = "camelCase")]
47
+ pub struct TelemetryDto {
48
+ pub elapsed_ms: u64,
49
+ pub step_count: u64,
50
+ pub moves_generated: u64,
51
+ pub moves_evaluated: u64,
52
+ pub moves_accepted: u64,
53
+ pub score_calculations: u64,
54
+ pub generation_ms: u64,
55
+ pub evaluation_ms: u64,
56
+ pub moves_per_second: u64,
57
+ pub acceptance_rate: f64,
58
+ }
59
+
60
+ /// Compact job summary returned by `/jobs/{id}` and `/jobs/{id}/status`.
61
+ #[derive(Debug, Clone, Serialize)]
62
+ #[serde(rename_all = "camelCase")]
63
+ pub struct JobSummaryDto {
64
+ pub id: String,
65
+ pub job_id: String,
66
+ pub lifecycle_state: &'static str,
67
+ pub terminal_reason: Option<&'static str>,
68
+ pub checkpoint_available: bool,
69
+ pub event_sequence: u64,
70
+ pub snapshot_revision: Option<u64>,
71
+ pub current_score: Option<String>,
72
+ pub best_score: Option<String>,
73
+ pub telemetry: TelemetryDto,
74
+ }
75
+
76
+ /// Snapshot payload returned by `/jobs/{id}/snapshot`.
77
+ #[derive(Debug, Clone, Serialize)]
78
+ #[serde(rename_all = "camelCase")]
79
+ pub struct JobSnapshotDto {
80
+ pub id: String,
81
+ pub job_id: String,
82
+ pub snapshot_revision: u64,
83
+ pub lifecycle_state: &'static str,
84
+ pub terminal_reason: Option<&'static str>,
85
+ pub current_score: Option<String>,
86
+ pub best_score: Option<String>,
87
+ pub telemetry: TelemetryDto,
88
+ pub solution: PlanDto,
89
+ }
90
+
91
+ /// Analysis payload tied to a specific retained snapshot revision.
92
+ #[derive(Debug, Clone, Serialize)]
93
+ #[serde(rename_all = "camelCase")]
94
+ pub struct JobAnalysisDto {
95
+ pub id: String,
96
+ pub job_id: String,
97
+ pub snapshot_revision: u64,
98
+ pub lifecycle_state: &'static str,
99
+ pub terminal_reason: Option<&'static str>,
100
+ pub analysis: AnalyzeResponse,
101
+ }
102
+
103
+ impl PlanDto {
104
+ /// Captures the domain plan as a JSON object while keeping `score` explicit.
105
+ pub fn from_plan(plan: &Plan) -> Self {
106
+ let mut fields = plan.to_transport_fields();
107
+ fields.remove("score");
108
+
109
+ Self {
110
+ fields,
111
+ score: plan.score.map(|score| score.to_string()),
112
+ }
113
+ }
114
+
115
+ /// Rebuilds the normalized domain model from the flattened JSON payload.
116
+ pub fn to_domain(&self) -> Result<Plan, serde_json::Error> {
117
+ Plan::from_transport_fields(self.fields.clone())
118
+ }
119
+ }
120
+
121
+ impl TelemetryDto {
122
+ /// Projects exact runtime telemetry into the fields the browser displays.
123
+ pub fn from_runtime(telemetry: &SolverTelemetry) -> Self {
124
+ Self {
125
+ elapsed_ms: duration_millis_u64(telemetry.elapsed),
126
+ step_count: telemetry.step_count,
127
+ moves_generated: telemetry.moves_generated,
128
+ moves_evaluated: telemetry.moves_evaluated,
129
+ moves_accepted: telemetry.moves_accepted,
130
+ score_calculations: telemetry.score_calculations,
131
+ generation_ms: duration_millis_u64(telemetry.generation_time),
132
+ evaluation_ms: duration_millis_u64(telemetry.evaluation_time),
133
+ moves_per_second: moves_per_second(telemetry.moves_evaluated, telemetry.elapsed),
134
+ acceptance_rate: acceptance_rate(telemetry.moves_accepted, telemetry.moves_evaluated),
135
+ }
136
+ }
137
+ }
138
+
139
+ /// Small helper so the JSON surface stays integer-based.
140
+ fn duration_millis_u64(duration: Duration) -> u64 {
141
+ u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
142
+ }
143
+
144
+ /// Derives whole moves per second from exact elapsed time.
145
+ fn moves_per_second(moves_evaluated: u64, elapsed: Duration) -> u64 {
146
+ let nanos = elapsed.as_nanos();
147
+ if nanos == 0 {
148
+ return 0;
149
+ }
150
+
151
+ let rate = u128::from(moves_evaluated)
152
+ .saturating_mul(1_000_000_000)
153
+ .checked_div(nanos)
154
+ .unwrap_or(0);
155
+ u64::try_from(rate).unwrap_or(u64::MAX)
156
+ }
157
+
158
+ /// Derives acceptance as a decimal fraction for easy frontend display.
159
+ fn acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 {
160
+ if moves_evaluated == 0 {
161
+ 0.0
162
+ } else {
163
+ moves_accepted as f64 / moves_evaluated as f64
164
+ }
165
+ }
166
+
167
+ impl JobSummaryDto {
168
+ /// Converts the runtime status summary into the JSON contract used by the app.
169
+ pub fn from_status(job_id: usize, status: &SolverStatus<HardSoftDecimalScore>) -> Self {
170
+ Self {
171
+ id: job_id.to_string(),
172
+ job_id: job_id.to_string(),
173
+ lifecycle_state: lifecycle_state_label(status.lifecycle_state),
174
+ terminal_reason: status.terminal_reason.map(terminal_reason_label),
175
+ checkpoint_available: status.checkpoint_available,
176
+ event_sequence: status.event_sequence,
177
+ snapshot_revision: status.latest_snapshot_revision,
178
+ current_score: status.current_score.map(|score| score.to_string()),
179
+ best_score: status.best_score.map(|score| score.to_string()),
180
+ telemetry: TelemetryDto::from_runtime(&status.telemetry),
181
+ }
182
+ }
183
+ }
184
+
185
+ impl JobSnapshotDto {
186
+ /// Converts a retained snapshot into the richer snapshot JSON payload.
187
+ pub fn from_snapshot(snapshot: &SolverSnapshot<Plan>) -> Self {
188
+ Self {
189
+ id: snapshot.job_id.to_string(),
190
+ job_id: snapshot.job_id.to_string(),
191
+ snapshot_revision: snapshot.snapshot_revision,
192
+ lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
193
+ terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
194
+ current_score: snapshot.current_score.map(|score| score.to_string()),
195
+ best_score: snapshot.best_score.map(|score| score.to_string()),
196
+ telemetry: TelemetryDto::from_runtime(&snapshot.telemetry),
197
+ solution: PlanDto::from_plan(&snapshot.solution),
198
+ }
199
+ }
200
+ }
201
+
202
+ impl JobAnalysisDto {
203
+ /// Packages exact snapshot analysis together with snapshot identity metadata.
204
+ pub fn from_snapshot_analysis(
205
+ snapshot: &SolverSnapshotAnalysis<HardSoftDecimalScore>,
206
+ analysis: AnalyzeResponse,
207
+ ) -> Self {
208
+ Self {
209
+ id: snapshot.job_id.to_string(),
210
+ job_id: snapshot.job_id.to_string(),
211
+ snapshot_revision: snapshot.snapshot_revision,
212
+ lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
213
+ terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
214
+ analysis,
215
+ }
216
+ }
217
+ }
218
+
219
+ /// Converts SolverForge's detailed score analysis into the browser response shape.
220
+ pub fn analysis_response(analysis: &ScoreAnalysis<HardSoftDecimalScore>) -> AnalyzeResponse {
221
+ AnalyzeResponse {
222
+ score: analysis.score.to_string(),
223
+ constraints: analysis
224
+ .constraints
225
+ .iter()
226
+ .map(|constraint| ConstraintAnalysisDto {
227
+ name: constraint.name.clone(),
228
+ weight: constraint.weight.to_string(),
229
+ score: constraint.score.to_string(),
230
+ match_count: constraint.match_count,
231
+ })
232
+ .collect(),
233
+ }
234
+ }
235
+
236
+ /// Re-exports lifecycle labels so routes and tests share one mapping.
237
+ pub fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str {
238
+ match state {
239
+ SolverLifecycleState::Solving => "SOLVING",
240
+ SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED",
241
+ SolverLifecycleState::Paused => "PAUSED",
242
+ SolverLifecycleState::Completed => "COMPLETED",
243
+ SolverLifecycleState::Cancelled => "CANCELLED",
244
+ SolverLifecycleState::Failed => "FAILED",
245
+ }
246
+ }
247
+
248
+ /// Re-exports terminal labels so routes and tests share one mapping.
249
+ pub fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str {
250
+ match reason {
251
+ SolverTerminalReason::Completed => "completed",
252
+ SolverTerminalReason::TerminatedByConfig => "terminated_by_config",
253
+ SolverTerminalReason::Cancelled => "cancelled",
254
+ SolverTerminalReason::Failed => "failed",
255
+ }
256
+ }
257
+
258
+ #[cfg(test)]
259
+ mod tests;
src/api/dto/tests.rs ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! DTO tests that keep transport JSON and generated UI metadata aligned.
2
+
3
+ use super::*;
4
+ use serde::Deserialize;
5
+ use solverforge::ConstraintSet;
6
+ use std::fs;
7
+ use std::time::Duration;
8
+
9
+ #[derive(Deserialize)]
10
+ struct UiModel {
11
+ entities: Vec<UiNamedEntry>,
12
+ facts: Vec<UiNamedEntry>,
13
+ constraints: Vec<UiConstraint>,
14
+ views: Vec<UiView>,
15
+ }
16
+
17
+ #[derive(Debug, Deserialize, PartialEq, Eq)]
18
+ struct UiConstraint {
19
+ name: String,
20
+ #[serde(rename = "type")]
21
+ constraint_type: String,
22
+ }
23
+
24
+ #[derive(Debug, Deserialize, PartialEq, Eq)]
25
+ struct UiNamedEntry {
26
+ name: String,
27
+ plural: String,
28
+ label: String,
29
+ }
30
+
31
+ #[derive(Debug, Deserialize, PartialEq, Eq)]
32
+ #[serde(rename_all = "camelCase")]
33
+ struct UiView {
34
+ id: String,
35
+ kind: String,
36
+ label: String,
37
+ entity: String,
38
+ entity_plural: String,
39
+ source_plural: String,
40
+ variable_field: String,
41
+ allows_unassigned: bool,
42
+ }
43
+
44
+ #[test]
45
+ fn plan_dto_returns_decode_errors_for_semantically_invalid_payloads() {
46
+ let dto = PlanDto {
47
+ fields: Map::new(),
48
+ score: None,
49
+ };
50
+
51
+ assert!(dto.to_domain().is_err());
52
+ }
53
+
54
+ #[test]
55
+ fn runtime_telemetry_derives_stock_transport_fields() {
56
+ let telemetry = SolverTelemetry {
57
+ elapsed: Duration::from_millis(2_500),
58
+ step_count: 9,
59
+ moves_generated: 300,
60
+ moves_evaluated: 200,
61
+ moves_accepted: 50,
62
+ score_calculations: 80,
63
+ generation_time: Duration::from_millis(400),
64
+ evaluation_time: Duration::from_millis(900),
65
+ ..SolverTelemetry::default()
66
+ };
67
+
68
+ let dto = TelemetryDto::from_runtime(&telemetry);
69
+
70
+ assert_eq!(dto.elapsed_ms, 2_500);
71
+ assert_eq!(dto.step_count, 9);
72
+ assert_eq!(dto.moves_generated, 300);
73
+ assert_eq!(dto.moves_evaluated, 200);
74
+ assert_eq!(dto.moves_accepted, 50);
75
+ assert_eq!(dto.score_calculations, 80);
76
+ assert_eq!(dto.generation_ms, 400);
77
+ assert_eq!(dto.evaluation_ms, 900);
78
+ assert_eq!(dto.moves_per_second, 80);
79
+ assert!((dto.acceptance_rate - 0.25).abs() < f64::EPSILON);
80
+ }
81
+
82
+ #[test]
83
+ fn analyzed_constraint_names_match_ui_model() {
84
+ let ui_model_path = concat!(
85
+ env!("CARGO_MANIFEST_DIR"),
86
+ "/static/generated/ui-model.json"
87
+ );
88
+ let ui_model: UiModel =
89
+ serde_json::from_str(&fs::read_to_string(ui_model_path).unwrap()).unwrap();
90
+
91
+ let plan = Plan::new(Vec::new(), Vec::new());
92
+ let constraints = crate::constraints::create_constraints();
93
+ let analysis = constraints.evaluate_detailed(&plan);
94
+ let analyzed_constraints: Vec<String> = analysis
95
+ .iter()
96
+ .map(|analysis| analysis.constraint_ref.name.to_string())
97
+ .collect();
98
+
99
+ let ui_constraints: Vec<String> = ui_model
100
+ .constraints
101
+ .iter()
102
+ .map(|constraint| constraint.name.clone())
103
+ .collect();
104
+ assert_eq!(analyzed_constraints, ui_constraints);
105
+ assert_eq!(
106
+ ui_model.entities,
107
+ vec![UiNamedEntry {
108
+ name: "shift".to_string(),
109
+ plural: "shifts".to_string(),
110
+ label: "Shifts".to_string(),
111
+ }]
112
+ );
113
+ assert_eq!(
114
+ ui_model.facts,
115
+ vec![UiNamedEntry {
116
+ name: "employee".to_string(),
117
+ plural: "employees".to_string(),
118
+ label: "Employees".to_string(),
119
+ }]
120
+ );
121
+ assert_eq!(
122
+ ui_model.views,
123
+ vec![
124
+ UiView {
125
+ id: "by-location".to_string(),
126
+ kind: "schedule-by-location".to_string(),
127
+ label: "By location".to_string(),
128
+ entity: "shift".to_string(),
129
+ entity_plural: "shifts".to_string(),
130
+ source_plural: "employees".to_string(),
131
+ variable_field: "employeeIdx".to_string(),
132
+ allows_unassigned: true,
133
+ },
134
+ UiView {
135
+ id: "by-employee".to_string(),
136
+ kind: "schedule-by-employee".to_string(),
137
+ label: "By employee".to_string(),
138
+ entity: "shift".to_string(),
139
+ entity_plural: "shifts".to_string(),
140
+ source_plural: "employees".to_string(),
141
+ variable_field: "employeeIdx".to_string(),
142
+ allows_unassigned: true,
143
+ }
144
+ ]
145
+ );
146
+ }
src/api/mod.rs ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! HTTP transport surface for the example application.
2
+ //!
3
+ //! The API layer is split into:
4
+ //! - DTOs that translate runtime/domain types into JSON
5
+ //! - routes that bind those DTOs to HTTP endpoints
6
+ //! - SSE glue for live lifecycle updates
7
+
8
+ mod dto;
9
+ mod routes;
10
+ mod sse;
11
+
12
+ pub use dto::PlanDto;
13
+ pub use routes::{router, AppState};
src/api/routes.rs ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! HTTP routes for the hospital example.
2
+ //!
3
+ //! These handlers stay intentionally small: each one should read like
4
+ //! "decode request -> call `SolverService` -> encode response".
5
+
6
+ use axum::{
7
+ extract::{Path, Query, State},
8
+ http::StatusCode,
9
+ routing::{get, post},
10
+ Json, Router,
11
+ };
12
+ use serde::{Deserialize, Serialize};
13
+ use std::sync::Arc;
14
+
15
+ use super::dto::{analysis_response, JobAnalysisDto, JobSnapshotDto, JobSummaryDto, PlanDto};
16
+ use super::sse;
17
+ use crate::data::{self, DemoData};
18
+ use crate::solver::SolverService;
19
+
20
+ /// Shared application state stored inside Axum.
21
+ pub struct AppState {
22
+ pub solver: SolverService,
23
+ }
24
+
25
+ impl AppState {
26
+ /// Builds the shared runtime facade once for the whole router.
27
+ pub fn new() -> Self {
28
+ Self {
29
+ solver: SolverService::new(),
30
+ }
31
+ }
32
+ }
33
+
34
+ impl Default for AppState {
35
+ fn default() -> Self {
36
+ Self::new()
37
+ }
38
+ }
39
+
40
+ /// Registers the full public HTTP surface of the example app.
41
+ pub fn router(state: Arc<AppState>) -> Router {
42
+ Router::new()
43
+ .route("/health", get(health))
44
+ .route("/info", get(info))
45
+ .route("/demo-data", get(list_demo_data))
46
+ .route("/demo-data/{id}", get(get_demo_data))
47
+ .route("/jobs", post(create_job))
48
+ .route("/jobs/{id}", get(get_job).delete(delete_job))
49
+ .route("/jobs/{id}/status", get(get_job_status))
50
+ .route("/jobs/{id}/snapshot", get(get_snapshot))
51
+ .route("/jobs/{id}/analysis", get(analyze_by_id))
52
+ .route("/jobs/{id}/pause", post(pause_job))
53
+ .route("/jobs/{id}/resume", post(resume_job))
54
+ .route("/jobs/{id}/cancel", post(cancel_job))
55
+ .route("/jobs/{id}/events", get(sse::events))
56
+ .with_state(state)
57
+ }
58
+
59
+ #[derive(Serialize)]
60
+ struct HealthResponse {
61
+ status: &'static str,
62
+ }
63
+
64
+ /// Liveness probe used by demos and container platforms.
65
+ async fn health() -> Json<HealthResponse> {
66
+ Json(HealthResponse { status: "UP" })
67
+ }
68
+
69
+ #[derive(Serialize)]
70
+ #[serde(rename_all = "camelCase")]
71
+ struct InfoResponse {
72
+ name: &'static str,
73
+ version: &'static str,
74
+ solver_engine: &'static str,
75
+ }
76
+
77
+ /// Tiny self-description endpoint for the UI and quick manual checks.
78
+ async fn info() -> Json<InfoResponse> {
79
+ Json(InfoResponse {
80
+ name: env!("CARGO_PKG_NAME"),
81
+ version: env!("CARGO_PKG_VERSION"),
82
+ solver_engine: "SolverForge",
83
+ })
84
+ }
85
+
86
+ /// Lists the demo ids accepted by `/demo-data/{id}`.
87
+ async fn list_demo_data() -> Json<Vec<&'static str>> {
88
+ Json(data::list_demo_data())
89
+ }
90
+
91
+ /// Materializes one demo dataset and returns it as a `PlanDto`.
92
+ async fn get_demo_data(Path(id): Path<String>) -> Result<Json<PlanDto>, StatusCode> {
93
+ let demo = id.parse::<DemoData>().map_err(|_| StatusCode::NOT_FOUND)?;
94
+ Ok(Json(PlanDto::from_plan(&data::generate(demo))))
95
+ }
96
+
97
+ #[derive(Serialize)]
98
+ #[serde(rename_all = "camelCase")]
99
+ struct CreateJobResponse {
100
+ id: String,
101
+ }
102
+
103
+ /// Starts a retained solve for the posted plan payload.
104
+ async fn create_job(
105
+ State(state): State<Arc<AppState>>,
106
+ Json(dto): Json<PlanDto>,
107
+ ) -> Result<Json<CreateJobResponse>, StatusCode> {
108
+ let plan = dto.to_domain().map_err(|_| StatusCode::BAD_REQUEST)?;
109
+ let id = state
110
+ .solver
111
+ .start_job(plan)
112
+ .map_err(status_from_solver_error)?;
113
+ Ok(Json(CreateJobResponse { id }))
114
+ }
115
+
116
+ /// Returns the current retained-job summary.
117
+ async fn get_job(
118
+ State(state): State<Arc<AppState>>,
119
+ Path(id): Path<String>,
120
+ ) -> Result<Json<JobSummaryDto>, StatusCode> {
121
+ let job_id = parse_job_id(&id)?;
122
+ let status = state
123
+ .solver
124
+ .get_status(&id)
125
+ .map_err(status_from_solver_error)?;
126
+ Ok(Json(JobSummaryDto::from_status(job_id, &status)))
127
+ }
128
+
129
+ /// Alias route kept for the stock job-summary URL shape.
130
+ async fn get_job_status(
131
+ State(state): State<Arc<AppState>>,
132
+ Path(id): Path<String>,
133
+ ) -> Result<Json<JobSummaryDto>, StatusCode> {
134
+ get_job(State(state), Path(id)).await
135
+ }
136
+
137
+ #[derive(Debug, Default, Deserialize)]
138
+ struct SnapshotQuery {
139
+ snapshot_revision: Option<u64>,
140
+ }
141
+
142
+ /// Fetches either the latest retained snapshot or an exact revision.
143
+ async fn get_snapshot(
144
+ State(state): State<Arc<AppState>>,
145
+ Path(id): Path<String>,
146
+ Query(query): Query<SnapshotQuery>,
147
+ ) -> Result<Json<JobSnapshotDto>, StatusCode> {
148
+ let snapshot = state
149
+ .solver
150
+ .get_snapshot(&id, query.snapshot_revision)
151
+ .map_err(status_from_solver_error)?;
152
+ Ok(Json(JobSnapshotDto::from_snapshot(&snapshot)))
153
+ }
154
+
155
+ /// Runs exact score analysis against a retained snapshot revision.
156
+ async fn analyze_by_id(
157
+ State(state): State<Arc<AppState>>,
158
+ Path(id): Path<String>,
159
+ Query(query): Query<SnapshotQuery>,
160
+ ) -> Result<Json<JobAnalysisDto>, StatusCode> {
161
+ let snapshot_analysis = state
162
+ .solver
163
+ .analyze_snapshot(&id, query.snapshot_revision)
164
+ .map_err(status_from_solver_error)?;
165
+ let analysis = analysis_response(&snapshot_analysis.analysis);
166
+ Ok(Json(JobAnalysisDto::from_snapshot_analysis(
167
+ &snapshot_analysis,
168
+ analysis,
169
+ )))
170
+ }
171
+
172
+ /// Requests that the runtime pause the job at the next exact safe point.
173
+ async fn pause_job(
174
+ State(state): State<Arc<AppState>>,
175
+ Path(id): Path<String>,
176
+ ) -> Result<StatusCode, StatusCode> {
177
+ state.solver.pause(&id).map_err(status_from_solver_error)?;
178
+ Ok(StatusCode::ACCEPTED)
179
+ }
180
+
181
+ /// Resumes a paused retained job.
182
+ async fn resume_job(
183
+ State(state): State<Arc<AppState>>,
184
+ Path(id): Path<String>,
185
+ ) -> Result<StatusCode, StatusCode> {
186
+ state.solver.resume(&id).map_err(status_from_solver_error)?;
187
+ Ok(StatusCode::ACCEPTED)
188
+ }
189
+
190
+ /// Cancels a live or paused retained job.
191
+ async fn cancel_job(
192
+ State(state): State<Arc<AppState>>,
193
+ Path(id): Path<String>,
194
+ ) -> Result<StatusCode, StatusCode> {
195
+ state.solver.cancel(&id).map_err(status_from_solver_error)?;
196
+ Ok(StatusCode::ACCEPTED)
197
+ }
198
+
199
+ /// Deletes a terminal retained job and its cached SSE state.
200
+ async fn delete_job(
201
+ State(state): State<Arc<AppState>>,
202
+ Path(id): Path<String>,
203
+ ) -> Result<StatusCode, StatusCode> {
204
+ state.solver.delete(&id).map_err(status_from_solver_error)?;
205
+ Ok(StatusCode::NO_CONTENT)
206
+ }
207
+
208
+ /// Parses the path segment into the numeric runtime job id.
209
+ fn parse_job_id(id: &str) -> Result<usize, StatusCode> {
210
+ id.parse::<usize>().map_err(|_| StatusCode::NOT_FOUND)
211
+ }
212
+
213
+ /// Maps stock runtime errors onto the HTTP semantics the UI expects.
214
+ fn status_from_solver_error(error: solverforge::SolverManagerError) -> StatusCode {
215
+ match error {
216
+ solverforge::SolverManagerError::NoFreeJobSlots => StatusCode::SERVICE_UNAVAILABLE,
217
+ solverforge::SolverManagerError::JobNotFound { .. } => StatusCode::NOT_FOUND,
218
+ solverforge::SolverManagerError::InvalidStateTransition { .. } => StatusCode::CONFLICT,
219
+ solverforge::SolverManagerError::NoSnapshotAvailable { .. } => StatusCode::CONFLICT,
220
+ solverforge::SolverManagerError::SnapshotNotFound { .. } => StatusCode::NOT_FOUND,
221
+ }
222
+ }
223
+
224
+ #[cfg(test)]
225
+ mod tests;
src/api/routes/tests.rs ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Route tests for the retained-job HTTP contract.
2
+
3
+ use super::*;
4
+ use axum::body::{to_bytes, Body};
5
+ use axum::http::Request;
6
+ use tower::util::ServiceExt;
7
+
8
+ use crate::domain::{Employee, Plan};
9
+
10
+ fn empty_plan() -> PlanDto {
11
+ PlanDto::from_plan(&Plan::new(
12
+ vec![Employee::new(0, "Alex").with_skill("Doctor")],
13
+ vec![],
14
+ ))
15
+ }
16
+
17
+ fn heavy_plan() -> PlanDto {
18
+ PlanDto::from_plan(&data::generate(DemoData::Large))
19
+ }
20
+
21
+ async fn json_body(response: axum::response::Response) -> serde_json::Value {
22
+ let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
23
+ serde_json::from_slice(&body).unwrap()
24
+ }
25
+
26
+ async fn post_plan(app: &Router, path: &str, plan: &PlanDto) -> axum::response::Response {
27
+ post_json_bytes(app, path, serde_json::to_vec(plan).unwrap()).await
28
+ }
29
+
30
+ async fn post_json_bytes(app: &Router, path: &str, body: Vec<u8>) -> axum::response::Response {
31
+ app.clone()
32
+ .oneshot(
33
+ Request::post(path)
34
+ .header("content-type", "application/json")
35
+ .body(Body::from(body))
36
+ .unwrap(),
37
+ )
38
+ .await
39
+ .unwrap()
40
+ }
41
+
42
+ async fn request(app: &Router, method: &str, path: &str) -> axum::response::Response {
43
+ app.clone()
44
+ .oneshot(
45
+ Request::builder()
46
+ .method(method)
47
+ .uri(path)
48
+ .body(Body::empty())
49
+ .unwrap(),
50
+ )
51
+ .await
52
+ .unwrap()
53
+ }
54
+
55
+ async fn wait_for_status(
56
+ app: &Router,
57
+ id: &str,
58
+ predicate: impl Fn(&serde_json::Value) -> bool,
59
+ ) -> serde_json::Value {
60
+ for _ in 0..200 {
61
+ let response = request(app, "GET", &format!("/jobs/{id}")).await;
62
+ if response.status() == StatusCode::OK {
63
+ let json = json_body(response).await;
64
+ if predicate(&json) {
65
+ return json;
66
+ }
67
+ }
68
+ tokio::time::sleep(std::time::Duration::from_millis(25)).await;
69
+ }
70
+ panic!("timed out waiting for job status");
71
+ }
72
+
73
+ async fn wait_for_ok_json(app: &Router, path: &str) -> serde_json::Value {
74
+ for _ in 0..200 {
75
+ let response = request(app, "GET", path).await;
76
+ if response.status() == StatusCode::OK {
77
+ return json_body(response).await;
78
+ }
79
+ tokio::time::sleep(std::time::Duration::from_millis(25)).await;
80
+ }
81
+ panic!("timed out waiting for successful response on {path}");
82
+ }
83
+
84
+ async fn wait_for_terminal(app: &Router, id: &str) -> serde_json::Value {
85
+ wait_for_status(app, id, |json| {
86
+ matches!(
87
+ json["lifecycleState"].as_str(),
88
+ Some("COMPLETED") | Some("CANCELLED") | Some("FAILED")
89
+ )
90
+ })
91
+ .await
92
+ }
93
+
94
+ async fn cleanup_job(app: &Router, id: &str) {
95
+ let status = request(app, "GET", &format!("/jobs/{id}")).await;
96
+ if status.status() == StatusCode::NOT_FOUND {
97
+ return;
98
+ }
99
+
100
+ let summary = json_body(status).await;
101
+ let lifecycle = summary["lifecycleState"].as_str().unwrap_or("");
102
+ if !matches!(lifecycle, "COMPLETED" | "CANCELLED" | "FAILED") {
103
+ let cancel = request(app, "POST", &format!("/jobs/{id}/cancel")).await;
104
+ assert!(
105
+ cancel.status() == StatusCode::ACCEPTED || cancel.status() == StatusCode::CONFLICT,
106
+ "unexpected cancel status {}",
107
+ cancel.status()
108
+ );
109
+ let _ = wait_for_terminal(app, id).await;
110
+ }
111
+
112
+ let delete = request(app, "DELETE", &format!("/jobs/{id}")).await;
113
+ assert!(
114
+ delete.status() == StatusCode::NO_CONTENT || delete.status() == StatusCode::NOT_FOUND,
115
+ "unexpected delete status {}",
116
+ delete.status()
117
+ );
118
+ }
119
+
120
+ #[tokio::test]
121
+ async fn stock_jobs_contract_is_exposed_without_schedule_compatibility() {
122
+ let app = router(Arc::new(AppState::new()));
123
+
124
+ let create = post_plan(&app, "/jobs", &empty_plan()).await;
125
+ assert_eq!(create.status(), StatusCode::OK);
126
+ let create_json = json_body(create).await;
127
+ let terminal_id = create_json["id"].as_str().unwrap().to_string();
128
+ assert!(!terminal_id.is_empty());
129
+ assert!(create_json.get("jobId").is_none());
130
+
131
+ let summary = wait_for_status(&app, &terminal_id, |_| true).await;
132
+ assert_eq!(summary["id"], terminal_id);
133
+ assert_eq!(summary["jobId"], terminal_id);
134
+ assert!(summary.get("lifecycleState").is_some());
135
+ assert!(summary.get("checkpointAvailable").is_some());
136
+ assert!(summary.get("eventSequence").is_some());
137
+ assert!(summary.get("telemetry").is_some());
138
+
139
+ let snapshot = wait_for_ok_json(&app, &format!("/jobs/{terminal_id}/snapshot")).await;
140
+ assert_eq!(snapshot["id"], terminal_id);
141
+ assert_eq!(snapshot["jobId"], terminal_id);
142
+ assert!(snapshot.get("snapshotRevision").is_some());
143
+ assert!(snapshot.get("solution").is_some());
144
+
145
+ let cancel_empty = request(&app, "POST", &format!("/jobs/{terminal_id}/cancel")).await;
146
+ assert_eq!(cancel_empty.status(), StatusCode::ACCEPTED);
147
+ let terminal = wait_for_terminal(&app, &terminal_id).await;
148
+ assert_eq!(terminal["lifecycleState"], "CANCELLED");
149
+
150
+ let analysis = wait_for_ok_json(&app, &format!("/jobs/{terminal_id}/analysis")).await;
151
+ assert_eq!(analysis["id"], terminal_id);
152
+ assert_eq!(analysis["jobId"], terminal_id);
153
+ assert!(analysis.get("analysis").is_some());
154
+
155
+ let cancel_again = request(&app, "POST", &format!("/jobs/{terminal_id}/cancel")).await;
156
+ assert_eq!(cancel_again.status(), StatusCode::CONFLICT);
157
+
158
+ let delete_terminal = request(&app, "DELETE", &format!("/jobs/{terminal_id}")).await;
159
+ assert_eq!(delete_terminal.status(), StatusCode::NO_CONTENT);
160
+
161
+ let missing_status = request(&app, "GET", &format!("/jobs/{terminal_id}/status")).await;
162
+ assert_eq!(missing_status.status(), StatusCode::NOT_FOUND);
163
+
164
+ let live_create = post_plan(&app, "/jobs", &heavy_plan()).await;
165
+ assert_eq!(live_create.status(), StatusCode::OK);
166
+ let live_id = json_body(live_create).await["id"]
167
+ .as_str()
168
+ .unwrap()
169
+ .to_string();
170
+
171
+ let _ = wait_for_status(&app, &live_id, |json| {
172
+ json["lifecycleState"] == "SOLVING" || json["lifecycleState"] == "PAUSE_REQUESTED"
173
+ })
174
+ .await;
175
+ let delete_live = request(&app, "DELETE", &format!("/jobs/{live_id}")).await;
176
+ assert_eq!(delete_live.status(), StatusCode::CONFLICT);
177
+
178
+ let pause = request(&app, "POST", &format!("/jobs/{live_id}/pause")).await;
179
+ assert_eq!(pause.status(), StatusCode::ACCEPTED);
180
+ let _ = wait_for_status(&app, &live_id, |json| json["lifecycleState"] == "PAUSED").await;
181
+
182
+ let delete_paused = request(&app, "DELETE", &format!("/jobs/{live_id}")).await;
183
+ assert_eq!(delete_paused.status(), StatusCode::CONFLICT);
184
+
185
+ let resume = request(&app, "POST", &format!("/jobs/{live_id}/resume")).await;
186
+ assert_eq!(resume.status(), StatusCode::ACCEPTED);
187
+ let _ = wait_for_status(&app, &live_id, |json| {
188
+ json["lifecycleState"] == "SOLVING" || json["lifecycleState"] == "PAUSE_REQUESTED"
189
+ })
190
+ .await;
191
+
192
+ let cancel = request(&app, "POST", &format!("/jobs/{live_id}/cancel")).await;
193
+ assert_eq!(cancel.status(), StatusCode::ACCEPTED);
194
+ let _ = wait_for_terminal(&app, &live_id).await;
195
+ let delete_cancelled = request(&app, "DELETE", &format!("/jobs/{live_id}")).await;
196
+ assert_eq!(delete_cancelled.status(), StatusCode::NO_CONTENT);
197
+
198
+ let mut full_ids = Vec::new();
199
+ for _ in 0..16 {
200
+ let response = post_plan(&app, "/jobs", &heavy_plan()).await;
201
+ if response.status() != StatusCode::OK {
202
+ break;
203
+ }
204
+ let id = json_body(response).await["id"]
205
+ .as_str()
206
+ .unwrap()
207
+ .to_string();
208
+ full_ids.push(id);
209
+ }
210
+ assert_eq!(
211
+ full_ids.len(),
212
+ 16,
213
+ "expected to occupy all 16 runtime job slots"
214
+ );
215
+
216
+ let full_response = post_plan(&app, "/jobs", &heavy_plan()).await;
217
+ assert_eq!(full_response.status(), StatusCode::SERVICE_UNAVAILABLE);
218
+
219
+ for id in full_ids {
220
+ cleanup_job(&app, &id).await;
221
+ }
222
+
223
+ let old_contract = request(&app, "POST", "/schedules").await;
224
+ assert_eq!(old_contract.status(), StatusCode::NOT_FOUND);
225
+ }
226
+
227
+ #[tokio::test]
228
+ async fn semantically_invalid_jobs_payload_returns_bad_request_without_killing_router() {
229
+ let app = router(Arc::new(AppState::new()));
230
+
231
+ let invalid = post_json_bytes(&app, "/jobs", br#"{}"#.to_vec()).await;
232
+ assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
233
+
234
+ let valid = post_plan(&app, "/jobs", &empty_plan()).await;
235
+ assert_eq!(valid.status(), StatusCode::OK);
236
+
237
+ let job_id = json_body(valid).await["id"].as_str().unwrap().to_string();
238
+ cleanup_job(&app, &job_id).await;
239
+ }
src/api/sse.rs ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Server-Sent Events endpoint for live solver lifecycle updates.
2
+
3
+ use axum::{
4
+ body::Body,
5
+ extract::{Path, State},
6
+ http::{header, StatusCode},
7
+ response::Response,
8
+ };
9
+ use std::sync::Arc;
10
+ use tokio_stream::wrappers::BroadcastStream;
11
+ use tokio_stream::StreamExt;
12
+
13
+ use super::routes::AppState;
14
+
15
+ /// Streams one bootstrap event followed by all future live job events.
16
+ pub async fn events(
17
+ State(state): State<Arc<AppState>>,
18
+ Path(id): Path<String>,
19
+ ) -> Result<Response<Body>, StatusCode> {
20
+ let rx = state.solver.subscribe(&id).ok_or(StatusCode::NOT_FOUND)?;
21
+ let bootstrap_json = state
22
+ .solver
23
+ .bootstrap_event(&id)
24
+ .map_err(|_| StatusCode::NOT_FOUND)?;
25
+ let bootstrap_event_sequence = event_sequence_from_json(&bootstrap_json);
26
+ // New clients first receive the latest known state so the UI can render
27
+ // immediately instead of waiting for the next live runtime event.
28
+ let bootstrap = tokio_stream::iter(std::iter::once(Ok::<_, std::convert::Infallible>(
29
+ format!("data: {}\n\n", bootstrap_json).into_bytes(),
30
+ )));
31
+
32
+ // After the bootstrap event we forward every future retained-job update as
33
+ // a normal SSE `data:` frame.
34
+ let live = BroadcastStream::new(rx).filter_map(move |msg| match msg {
35
+ Ok(json) => {
36
+ if event_is_not_newer(&json, bootstrap_event_sequence) {
37
+ return None;
38
+ }
39
+ Some(Ok::<_, std::convert::Infallible>(
40
+ format!("data: {}\n\n", json).into_bytes(),
41
+ ))
42
+ }
43
+ Err(_) => None,
44
+ });
45
+
46
+ let stream = bootstrap.chain(live);
47
+
48
+ Ok(Response::builder()
49
+ .header(header::CONTENT_TYPE, "text/event-stream")
50
+ .header(header::CACHE_CONTROL, "no-cache")
51
+ .header("X-Accel-Buffering", "no")
52
+ .body(Body::from_stream(stream))
53
+ .unwrap())
54
+ }
55
+
56
+ /// Reads lifecycle sequence metadata so bootstrap and live frames do not duplicate.
57
+ fn event_sequence_from_json(json: &str) -> Option<u64> {
58
+ serde_json::from_str::<serde_json::Value>(json)
59
+ .ok()
60
+ .and_then(|value| {
61
+ value
62
+ .get("eventSequence")
63
+ .and_then(serde_json::Value::as_u64)
64
+ })
65
+ }
66
+
67
+ /// Drops live events already represented by the bootstrap status snapshot.
68
+ fn event_is_not_newer(json: &str, bootstrap_event_sequence: Option<u64>) -> bool {
69
+ let Some(bootstrap_event_sequence) = bootstrap_event_sequence else {
70
+ return false;
71
+ };
72
+ event_sequence_from_json(json)
73
+ .is_some_and(|event_sequence| event_sequence <= bootstrap_event_sequence)
74
+ }
src/constraints/assigned_shift.rs ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Plan, PlanConstraintStreams};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ const SCORE_SCALE: i64 = 100_000;
6
+
7
+ /// Hard-penalizes each shift whose scalar planning variable is still unassigned.
8
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftDecimalScore> {
9
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new()
10
+ .shifts()
11
+ .unassigned()
12
+ .penalize(HardSoftDecimalScore::of_hard_scaled(SCORE_SCALE))
13
+ .named("Assigned shift")
14
+ }
src/constraints/balance_assignments.rs ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Plan, PlanConstraintStreams, Shift};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// Softly penalizes uneven distribution of assigned shifts by `employee_idx`.
6
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftDecimalScore> {
7
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new()
8
+ .shifts()
9
+ .balance(|shift: &Shift| shift.employee_idx)
10
+ .penalize(HardSoftDecimalScore::one_soft())
11
+ .named("Balance employee assignments")
12
+ }
src/constraints/desired_day.rs ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Employee, Plan, PlanConstraintStreams, Shift};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// Rewards assigning an employee to dates they explicitly prefer.
6
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftDecimalScore> {
7
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new()
8
+ .shifts()
9
+ .filter(|shift: &Shift| shift.employee_idx.is_some())
10
+ .join((
11
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new().employees(),
12
+ joiner::equal_bi(
13
+ |shift: &Shift| shift.employee_idx,
14
+ |employee: &Employee| Some(employee.index),
15
+ ),
16
+ ))
17
+ .filter(|shift: &Shift, employee: &Employee| {
18
+ employee
19
+ .desired_days
20
+ .iter()
21
+ .any(|date| shift.touched_dates().contains(date))
22
+ })
23
+ .reward(|shift: &Shift, employee: &Employee| {
24
+ HardSoftDecimalScore::of_soft(
25
+ employee
26
+ .desired_days
27
+ .iter()
28
+ .filter(|date| shift.touched_dates().contains(date))
29
+ .count() as i64,
30
+ )
31
+ })
32
+ .named("Desired day for employee")
33
+ }
src/constraints/minimum_rest.rs ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Plan, PlanConstraintStreams, Shift};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ const SCORE_SCALE: i64 = 100_000;
6
+ const STRUCTURAL_MINUTE_HARD_UNITS: i64 = 20;
7
+
8
+ /// Hard-penalizes same-employee shift pairs separated by less than 10 hours.
9
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftDecimalScore> {
10
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new()
11
+ .shifts()
12
+ .filter(|shift: &Shift| shift.employee_idx.is_some())
13
+ .join(joiner::equal(|shift: &Shift| shift.employee_idx))
14
+ .filter(|a: &Shift, b: &Shift| {
15
+ if a.index >= b.index {
16
+ return false;
17
+ }
18
+
19
+ let (earlier, later) = if a.end <= b.start {
20
+ (a, b)
21
+ } else if b.end <= a.start {
22
+ (b, a)
23
+ } else {
24
+ return false;
25
+ };
26
+
27
+ let gap_minutes = (later.start - earlier.end).num_minutes();
28
+ (0..600).contains(&gap_minutes)
29
+ })
30
+ .penalize(hard_weight(|a: &Shift, b: &Shift| {
31
+ let (earlier, later) = if a.end <= b.start { (a, b) } else { (b, a) };
32
+ let gap_minutes = (later.start - earlier.end).num_minutes();
33
+ HardSoftDecimalScore::of_hard_scaled(
34
+ (600 - gap_minutes) * STRUCTURAL_MINUTE_HARD_UNITS * SCORE_SCALE,
35
+ )
36
+ }))
37
+ .named("At least 10 hours between 2 shifts")
38
+ }
src/constraints/mod.rs ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #![cfg_attr(rustfmt, rustfmt_skip)]
2
+ //! Constraint assembly for employee scheduling.
3
+ //!
4
+ //! Each sibling module contributes one named rule. `create_constraints()`
5
+ //! simply lists them in the order we want them to appear in analysis output.
6
+
7
+ use crate::domain::Plan;
8
+ use solverforge::prelude::*;
9
+
10
+ pub use self::assemble::create_constraints;
11
+
12
+ // @solverforge:begin constraint-modules
13
+ mod assigned_shift;
14
+ mod required_skill;
15
+ mod overlapping_shift;
16
+ mod minimum_rest;
17
+ mod one_shift_per_day;
18
+ mod unavailable_employee;
19
+ mod undesired_day;
20
+ mod desired_day;
21
+ mod balance_assignments;
22
+ // @solverforge:end constraint-modules
23
+
24
+ mod assemble {
25
+ use super::*;
26
+
27
+ /// Collects the full scoring model used by `Plan`.
28
+ pub fn create_constraints() -> impl ConstraintSet<Plan, HardSoftDecimalScore> {
29
+ // @solverforge:begin constraint-calls
30
+ (
31
+ assigned_shift::constraint(),
32
+ required_skill::constraint(),
33
+ overlapping_shift::constraint(),
34
+ minimum_rest::constraint(),
35
+ one_shift_per_day::constraint(),
36
+ unavailable_employee::constraint(),
37
+ undesired_day::constraint(),
38
+ desired_day::constraint(),
39
+ balance_assignments::constraint(),
40
+ )
41
+ // @solverforge:end constraint-calls
42
+ }
43
+ }
src/constraints/one_shift_per_day.rs ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Plan, PlanConstraintStreams, Shift};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ const SCORE_SCALE: i64 = 100_000;
6
+
7
+ /// Hard-penalizes assigning two shifts that touch the same calendar day to one employee.
8
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftDecimalScore> {
9
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new()
10
+ .shifts()
11
+ .filter(|shift: &Shift| shift.employee_idx.is_some())
12
+ .join(joiner::equal(|shift: &Shift| shift.employee_idx))
13
+ .filter(|a: &Shift, b: &Shift| {
14
+ a.index < b.index
15
+ && a.touched_dates()
16
+ .iter()
17
+ .any(|date| b.touched_dates().contains(date))
18
+ })
19
+ .penalize(HardSoftDecimalScore::of_hard_scaled(20 * SCORE_SCALE))
20
+ .named("One shift per day")
21
+ }
src/constraints/overlapping_shift.rs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Plan, PlanConstraintStreams, Shift};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ const SCORE_SCALE: i64 = 100_000;
6
+ const STRUCTURAL_MINUTE_HARD_UNITS: i64 = 20;
7
+
8
+ /// Penalizes overlapping time windows for the same employee.
9
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftDecimalScore> {
10
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new()
11
+ .shifts()
12
+ .filter(|shift: &Shift| shift.employee_idx.is_some())
13
+ .join(joiner::equal(|shift: &Shift| shift.employee_idx))
14
+ .filter(|a: &Shift, b: &Shift| a.index < b.index && a.start < b.end && b.start < a.end)
15
+ .penalize(hard_weight(|a: &Shift, b: &Shift| {
16
+ let overlap_start = a.start.max(b.start);
17
+ let overlap_end = a.end.min(b.end);
18
+ let overlap_minutes = if overlap_start < overlap_end {
19
+ (overlap_end - overlap_start).num_minutes()
20
+ } else {
21
+ 0
22
+ };
23
+ HardSoftDecimalScore::of_hard_scaled(
24
+ overlap_minutes * STRUCTURAL_MINUTE_HARD_UNITS * SCORE_SCALE,
25
+ )
26
+ }))
27
+ .named("Overlapping shift")
28
+ }
src/constraints/required_skill.rs ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Employee, Plan, PlanConstraintStreams, Shift};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ const SCORE_SCALE: i64 = 100_000;
6
+
7
+ /// Penalizes assignments where the employee lacks the required skill label.
8
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftDecimalScore> {
9
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new()
10
+ .shifts()
11
+ .filter(|shift: &Shift| shift.employee_idx.is_some())
12
+ .join((
13
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new().employees(),
14
+ joiner::equal_bi(
15
+ |shift: &Shift| shift.employee_idx,
16
+ |employee: &Employee| Some(employee.index),
17
+ ),
18
+ ))
19
+ .filter(|shift: &Shift, employee: &Employee| {
20
+ !employee.skills.contains(&shift.required_skill)
21
+ })
22
+ .penalize(HardSoftDecimalScore::of_hard_scaled(10 * SCORE_SCALE))
23
+ .named("Required skill")
24
+ }
src/constraints/unavailable_employee.rs ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Employee, Plan, PlanConstraintStreams, Shift};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ const SCORE_SCALE: i64 = 100_000;
6
+ const STRUCTURAL_MINUTE_HARD_UNITS: i64 = 20;
7
+
8
+ /// Hard-penalizes unavailable-date overlap, scaled by overlapping minutes.
9
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftDecimalScore> {
10
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new()
11
+ .shifts()
12
+ .filter(|shift: &Shift| shift.employee_idx.is_some())
13
+ .join((
14
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new().employees(),
15
+ joiner::equal_bi(
16
+ |shift: &Shift| shift.employee_idx,
17
+ |employee: &Employee| Some(employee.index),
18
+ ),
19
+ ))
20
+ .filter(|shift: &Shift, employee: &Employee| {
21
+ employee.unavailable_days.iter().any(|date| {
22
+ let day_start = date.and_hms_opt(0, 0, 0).unwrap();
23
+ let day_end = date
24
+ .succ_opt()
25
+ .unwrap_or(*date)
26
+ .and_hms_opt(0, 0, 0)
27
+ .unwrap();
28
+ let overlap_start = shift.start.max(day_start);
29
+ let overlap_end = shift.end.min(day_end);
30
+ overlap_start < overlap_end
31
+ })
32
+ })
33
+ .penalize(hard_weight(|shift: &Shift, employee: &Employee| {
34
+ let overlap_minutes: i64 = employee
35
+ .unavailable_days
36
+ .iter()
37
+ .map(|date| {
38
+ let day_start = date.and_hms_opt(0, 0, 0).unwrap();
39
+ let day_end = date
40
+ .succ_opt()
41
+ .unwrap_or(*date)
42
+ .and_hms_opt(0, 0, 0)
43
+ .unwrap();
44
+ let overlap_start = shift.start.max(day_start);
45
+ let overlap_end = shift.end.min(day_end);
46
+ if overlap_start < overlap_end {
47
+ (overlap_end - overlap_start).num_minutes()
48
+ } else {
49
+ 0
50
+ }
51
+ })
52
+ .sum();
53
+ HardSoftDecimalScore::of_hard_scaled(
54
+ overlap_minutes * STRUCTURAL_MINUTE_HARD_UNITS * SCORE_SCALE,
55
+ )
56
+ }))
57
+ .named("Unavailable employee")
58
+ }
src/constraints/undesired_day.rs ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Employee, Plan, PlanConstraintStreams, Shift};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// Softly penalizes assignments that land on an employee's undesired dates.
6
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftDecimalScore> {
7
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new()
8
+ .shifts()
9
+ .filter(|shift: &Shift| shift.employee_idx.is_some())
10
+ .join((
11
+ ConstraintFactory::<Plan, HardSoftDecimalScore>::new().employees(),
12
+ joiner::equal_bi(
13
+ |shift: &Shift| shift.employee_idx,
14
+ |employee: &Employee| Some(employee.index),
15
+ ),
16
+ ))
17
+ .filter(|shift: &Shift, employee: &Employee| {
18
+ employee
19
+ .undesired_days
20
+ .iter()
21
+ .any(|date| shift.touched_dates().contains(date))
22
+ })
23
+ .penalize(|shift: &Shift, employee: &Employee| {
24
+ HardSoftDecimalScore::of_soft(
25
+ employee
26
+ .undesired_days
27
+ .iter()
28
+ .filter(|date| shift.touched_dates().contains(date))
29
+ .count() as i64,
30
+ )
31
+ })
32
+ .named("Undesired day for employee")
33
+ }
src/data/data_seed.rs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Public demo-data surface for the hospital example.
2
+ //!
3
+ //! Keep this file intentionally thin. The rest of the application imports
4
+ //! `crate::data::{generate, list_demo_data, DemoData}` as a stable boundary, so
5
+ //! the detailed dataset design lives in sibling modules where it can evolve
6
+ //! without making the top-level data surface noisy.
7
+
8
+ mod availability;
9
+ mod cohorts;
10
+ mod coverage;
11
+ mod demand;
12
+ mod employees;
13
+ mod entrypoints;
14
+ mod large;
15
+ mod preferences;
16
+ mod shifts;
17
+ mod skills;
18
+ mod time_utils;
19
+ mod validation;
20
+ mod vocabulary;
21
+ mod witness;
22
+
23
+ #[cfg(test)]
24
+ mod solve_tests;
25
+ #[cfg(test)]
26
+ mod tests;
27
+
28
+ pub use entrypoints::{generate, list_demo_data, DemoData};
src/data/data_seed/availability.rs ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::NaiveDate;
2
+ use std::cmp::Reverse;
3
+ use std::collections::BTreeSet;
4
+
5
+ use crate::domain::{Employee, Shift};
6
+
7
+ use super::coverage::{candidate_redundancy_is_valid, public_candidate_counts};
8
+ use super::time_utils::horizon_dates;
9
+ use super::vocabulary::EXTRA_UNAVAILABLE_COUNT;
10
+
11
+ /// Adds a small amount of extra unavailability without breaking public feasibility.
12
+ ///
13
+ /// The goal is not to make the dataset impossible. The goal is to remove some
14
+ /// trivial interchangeable assignments so local search has a clearer signal.
15
+ pub(super) fn add_extra_unavailability(
16
+ employees: &mut [Employee],
17
+ shifts: &[Shift],
18
+ witness_dates: &[BTreeSet<NaiveDate>],
19
+ ) {
20
+ let horizon_dates = horizon_dates(shifts);
21
+
22
+ for _ in 0..EXTRA_UNAVAILABLE_COUNT {
23
+ let best_candidate = (0..employees.len())
24
+ .flat_map(|employee_index| {
25
+ horizon_dates
26
+ .iter()
27
+ .copied()
28
+ .map(move |date| (employee_index, date))
29
+ })
30
+ .filter(|&(employee_index, date)| {
31
+ !employees[employee_index].unavailable_dates.contains(&date)
32
+ && !witness_dates[employee_index].contains(&date)
33
+ })
34
+ .filter_map(|(employee_index, date)| {
35
+ let score = extra_unavailability_score(employees, shifts, employee_index, date)?;
36
+ Some((score, employee_index, date))
37
+ })
38
+ .max_by_key(|&(score, employee_index, date)| {
39
+ (score, Reverse(employee_index), Reverse(date))
40
+ });
41
+
42
+ let Some((_, employee_index, date)) = best_candidate else {
43
+ break;
44
+ };
45
+ employees[employee_index].unavailable_dates.insert(date);
46
+ }
47
+ }
48
+
49
+ /// Scores one candidate "employee unavailable on date" mutation.
50
+ fn extra_unavailability_score(
51
+ employees: &[Employee],
52
+ shifts: &[Shift],
53
+ employee_index: usize,
54
+ date: NaiveDate,
55
+ ) -> Option<(usize, usize, usize)> {
56
+ let mut cloned: Vec<Employee> = employees.to_vec();
57
+ cloned[employee_index].unavailable_dates.insert(date);
58
+ if !candidate_redundancy_is_valid(&cloned, shifts) {
59
+ return None;
60
+ }
61
+
62
+ let counts = public_candidate_counts(&cloned, shifts);
63
+ let affected: Vec<usize> = shifts
64
+ .iter()
65
+ .enumerate()
66
+ .filter(|(_, shift)| shift.touched_dates.contains(&date))
67
+ .map(|(index, _)| counts[index])
68
+ .collect();
69
+ let min_affected = affected.into_iter().min().unwrap_or(usize::MAX);
70
+ let shifts_with_three_plus = counts.iter().filter(|&&count| count >= 3).count();
71
+ Some((min_affected, shifts_with_three_plus, counts.iter().sum()))
72
+ }
src/data/data_seed/cohorts.rs ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::cmp::Reverse;
2
+
3
+ use super::employees::EmployeeBlueprint;
4
+ use super::vocabulary::*;
5
+
6
+ /// Running totals for one weekday-off cohort.
7
+ ///
8
+ /// We use this to distribute scarce specialties across the seven primary
9
+ /// off-day groups instead of accidentally clustering too many similar people on
10
+ /// the same day off.
11
+ #[derive(Default, Clone, Copy)]
12
+ struct CohortLoad {
13
+ size: usize,
14
+ doctors: usize,
15
+ nurses: usize,
16
+ ambulatory_doctors: usize,
17
+ ambulatory_nurses: usize,
18
+ neurology_doctors: usize,
19
+ neurology_nurses: usize,
20
+ critical_doctors: usize,
21
+ critical_nurses: usize,
22
+ pediatric_doctors: usize,
23
+ pediatric_nurses: usize,
24
+ surgery_doctors: usize,
25
+ surgery_nurses: usize,
26
+ outpatient_doctors: usize,
27
+ outpatient_nurses: usize,
28
+ radiology_day: usize,
29
+ radiology_nurses: usize,
30
+ radiology_call: usize,
31
+ cardiology: usize,
32
+ anaesthetics: usize,
33
+ }
34
+
35
+ impl CohortLoad {
36
+ /// Updates the cohort totals after placing one blueprint into it.
37
+ fn add(&mut self, blueprint: &EmployeeBlueprint) {
38
+ self.size += 1;
39
+ if blueprint.skills.contains(DOCTOR) {
40
+ self.doctors += 1;
41
+ }
42
+ if blueprint.skills.contains(NURSE) {
43
+ self.nurses += 1;
44
+ }
45
+ if blueprint.skills.contains(AMBULATORY_DOCTOR) {
46
+ self.ambulatory_doctors += 1;
47
+ }
48
+ if blueprint.skills.contains(AMBULATORY_NURSE) {
49
+ self.ambulatory_nurses += 1;
50
+ }
51
+ if blueprint.skills.contains(NEUROLOGY_DOCTOR) {
52
+ self.neurology_doctors += 1;
53
+ }
54
+ if blueprint.skills.contains(NEUROLOGY_NURSE) {
55
+ self.neurology_nurses += 1;
56
+ }
57
+ if blueprint.skills.contains(CRITICAL_DOCTOR) {
58
+ self.critical_doctors += 1;
59
+ }
60
+ if blueprint.skills.contains(CRITICAL_NURSE) {
61
+ self.critical_nurses += 1;
62
+ }
63
+ if blueprint.skills.contains(PEDIATRIC_DOCTOR) {
64
+ self.pediatric_doctors += 1;
65
+ }
66
+ if blueprint.skills.contains(PEDIATRIC_NURSE) {
67
+ self.pediatric_nurses += 1;
68
+ }
69
+ if blueprint.skills.contains(SURGERY_DOCTOR) {
70
+ self.surgery_doctors += 1;
71
+ }
72
+ if blueprint.skills.contains(SURGERY_NURSE) {
73
+ self.surgery_nurses += 1;
74
+ }
75
+ if blueprint.skills.contains(OUTPATIENT_DOCTOR) {
76
+ self.outpatient_doctors += 1;
77
+ }
78
+ if blueprint.skills.contains(OUTPATIENT_NURSE) {
79
+ self.outpatient_nurses += 1;
80
+ }
81
+ if blueprint.skills.contains(RADIOLOGY_DAY) {
82
+ self.radiology_day += 1;
83
+ }
84
+ if blueprint.skills.contains(RADIOLOGY_NURSE) {
85
+ self.radiology_nurses += 1;
86
+ }
87
+ if blueprint.skills.contains(RADIOLOGY_CALL) {
88
+ self.radiology_call += 1;
89
+ }
90
+ if blueprint.skills.contains(CARDIOLOGY) {
91
+ self.cardiology += 1;
92
+ }
93
+ if blueprint.skills.contains(ANAESTHETICS) {
94
+ self.anaesthetics += 1;
95
+ }
96
+ }
97
+ }
98
+
99
+ /// Assigns each employee blueprint a stable primary off weekday.
100
+ pub(super) fn assign_primary_off_days(blueprints: &mut [EmployeeBlueprint]) {
101
+ let mut order: Vec<usize> = (0..blueprints.len()).collect();
102
+ order.sort_by_key(|&index| Reverse(blueprint_priority(&blueprints[index])));
103
+
104
+ let mut loads = [CohortLoad::default(); 7];
105
+
106
+ for employee_index in order {
107
+ let cohort = (0..7)
108
+ .filter(|&candidate| loads[candidate].size < PRIMARY_OFF_COHORT_SIZES[candidate])
109
+ .min_by_key(|&candidate| cohort_score(&loads[candidate], &blueprints[employee_index]))
110
+ .expect("cohort should have spare capacity");
111
+ blueprints[employee_index].primary_off_weekday = cohort;
112
+ loads[cohort].add(&blueprints[employee_index]);
113
+ }
114
+ }
115
+
116
+ /// Scarcer or more specialized blueprints get placed first.
117
+ fn blueprint_priority(blueprint: &EmployeeBlueprint) -> (usize, usize, usize, usize, usize) {
118
+ (
119
+ blueprint.specialty_count(),
120
+ usize::from(blueprint.skills.contains(DOCTOR)),
121
+ usize::from(blueprint.skills.contains(CARDIOLOGY)),
122
+ usize::from(blueprint.skills.contains(ANAESTHETICS)),
123
+ usize::from(blueprint.skills.contains(RADIOLOGY_CALL)),
124
+ )
125
+ }
126
+
127
+ /// Lower scores mean "this cohort needs this blueprint more".
128
+ fn cohort_score(load: &CohortLoad, blueprint: &EmployeeBlueprint) -> (usize, usize, usize, usize) {
129
+ let line_pressure = weighted_line_load(load, blueprint);
130
+ (
131
+ line_pressure,
132
+ if blueprint.skills.contains(DOCTOR) {
133
+ load.doctors
134
+ } else {
135
+ load.nurses
136
+ },
137
+ load.size,
138
+ blueprint.primary_off_weekday,
139
+ )
140
+ }
141
+
142
+ /// Applies weighted pressure so rare specialties dominate balancing decisions.
143
+ fn weighted_line_load(load: &CohortLoad, blueprint: &EmployeeBlueprint) -> usize {
144
+ let mut score = 0usize;
145
+ for (skill, weight) in line_balance_weights() {
146
+ if blueprint.has_skill(skill) {
147
+ score += line_load_for_skill(load, skill) * weight;
148
+ }
149
+ }
150
+ score
151
+ }
152
+
153
+ /// Manual weights that treat some specialties as harder to concentrate.
154
+ fn line_balance_weights() -> &'static [(&'static str, usize)] {
155
+ &[
156
+ (CARDIOLOGY, 8),
157
+ (RADIOLOGY_CALL, 8),
158
+ (ANAESTHETICS, 7),
159
+ (SURGERY_NURSE, 6),
160
+ (SURGERY_DOCTOR, 6),
161
+ (NEUROLOGY_DOCTOR, 6),
162
+ (RADIOLOGY_DAY, 5),
163
+ (RADIOLOGY_NURSE, 5),
164
+ (OUTPATIENT_DOCTOR, 5),
165
+ (OUTPATIENT_NURSE, 5),
166
+ (AMBULATORY_DOCTOR, 4),
167
+ (AMBULATORY_NURSE, 4),
168
+ (PEDIATRIC_DOCTOR, 4),
169
+ (PEDIATRIC_NURSE, 4),
170
+ (NEUROLOGY_NURSE, 4),
171
+ (CRITICAL_DOCTOR, 3),
172
+ (CRITICAL_NURSE, 3),
173
+ ]
174
+ }
175
+
176
+ /// Reads the current cohort count for one specific skill.
177
+ fn line_load_for_skill(load: &CohortLoad, skill: &'static str) -> usize {
178
+ match skill {
179
+ AMBULATORY_DOCTOR => load.ambulatory_doctors,
180
+ AMBULATORY_NURSE => load.ambulatory_nurses,
181
+ NEUROLOGY_DOCTOR => load.neurology_doctors,
182
+ NEUROLOGY_NURSE => load.neurology_nurses,
183
+ CRITICAL_DOCTOR => load.critical_doctors,
184
+ CRITICAL_NURSE => load.critical_nurses,
185
+ PEDIATRIC_DOCTOR => load.pediatric_doctors,
186
+ PEDIATRIC_NURSE => load.pediatric_nurses,
187
+ SURGERY_DOCTOR => load.surgery_doctors,
188
+ SURGERY_NURSE => load.surgery_nurses,
189
+ OUTPATIENT_DOCTOR => load.outpatient_doctors,
190
+ OUTPATIENT_NURSE => load.outpatient_nurses,
191
+ RADIOLOGY_DAY => load.radiology_day,
192
+ RADIOLOGY_NURSE => load.radiology_nurses,
193
+ RADIOLOGY_CALL => load.radiology_call,
194
+ CARDIOLOGY => load.cardiology,
195
+ ANAESTHETICS => load.anaesthetics,
196
+ _ => 0,
197
+ }
198
+ }
src/data/data_seed/coverage.rs ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::Timelike;
2
+
3
+ use crate::domain::{Employee, Shift};
4
+
5
+ use super::skills::is_specialty_skill;
6
+
7
+ /// Checks that the public dataset still has enough legal candidates per shift.
8
+ pub(super) fn candidate_redundancy_is_valid(employees: &[Employee], shifts: &[Shift]) -> bool {
9
+ let counts = public_candidate_counts(employees, shifts);
10
+ if counts.iter().any(|&count| count < 2) {
11
+ return false;
12
+ }
13
+ if counts.iter().filter(|&&count| count >= 3).count() * 4 < shifts.len() {
14
+ return false;
15
+ }
16
+ if shifts.iter().zip(counts.iter()).any(|(shift, &count)| {
17
+ is_specialty_skill(&shift.required_skill) && shift.start.time().hour() == 22 && count < 2
18
+ }) {
19
+ return false;
20
+ }
21
+ true
22
+ }
23
+
24
+ /// Counts legal candidates per shift without considering schedule interactions.
25
+ pub(super) fn public_candidate_counts(employees: &[Employee], shifts: &[Shift]) -> Vec<usize> {
26
+ shifts
27
+ .iter()
28
+ .map(|shift| {
29
+ employees
30
+ .iter()
31
+ .filter(|employee| employee_can_cover_shift_without_schedule(employee, shift))
32
+ .count()
33
+ })
34
+ .collect()
35
+ }
36
+
37
+ /// Coarse feasibility check used during dataset shaping.
38
+ pub(super) fn employee_can_cover_shift_without_schedule(
39
+ employee: &Employee,
40
+ shift: &Shift,
41
+ ) -> bool {
42
+ employee.skills.contains(&shift.required_skill)
43
+ && shift
44
+ .touched_dates
45
+ .iter()
46
+ .all(|date| !employee.unavailable_dates.contains(date))
47
+ }
src/data/data_seed/demand.rs ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::Weekday;
2
+
3
+ use super::vocabulary::*;
4
+
5
+ /// Reusable calendar patterns for demand templates.
6
+ #[derive(Clone, Copy)]
7
+ pub(super) enum WeekPattern {
8
+ Weekdays,
9
+ Weekends,
10
+ Daily,
11
+ MonWedFri,
12
+ Saturday,
13
+ Sunday,
14
+ }
15
+
16
+ #[derive(Clone, Copy)]
17
+ pub(super) struct DemandRule {
18
+ pub(super) location: &'static str,
19
+ pub(super) start_hour: u32,
20
+ pub(super) required_skill: &'static str,
21
+ pattern: WeekPattern,
22
+ pub(super) count: usize,
23
+ }
24
+
25
+ // This is the published demand template for the 28-day benchmark. Each rule
26
+ // says "on matching weekdays, create `count` eight-hour shifts of this shape".
27
+ pub(super) const DEMAND_RULES: &[DemandRule] = &[
28
+ DemandRule {
29
+ location: "Ambulatory care",
30
+ start_hour: 6,
31
+ required_skill: AMBULATORY_DOCTOR,
32
+ pattern: WeekPattern::Weekdays,
33
+ count: 1,
34
+ },
35
+ DemandRule {
36
+ location: "Ambulatory care",
37
+ start_hour: 14,
38
+ required_skill: AMBULATORY_NURSE,
39
+ pattern: WeekPattern::Weekdays,
40
+ count: 2,
41
+ },
42
+ DemandRule {
43
+ location: "Ambulatory care",
44
+ start_hour: 6,
45
+ required_skill: AMBULATORY_DOCTOR,
46
+ pattern: WeekPattern::Weekends,
47
+ count: 1,
48
+ },
49
+ DemandRule {
50
+ location: "Ambulatory care",
51
+ start_hour: 14,
52
+ required_skill: AMBULATORY_NURSE,
53
+ pattern: WeekPattern::Weekends,
54
+ count: 2,
55
+ },
56
+ DemandRule {
57
+ location: "Neurology",
58
+ start_hour: 6,
59
+ required_skill: NEUROLOGY_DOCTOR,
60
+ pattern: WeekPattern::Weekdays,
61
+ count: 1,
62
+ },
63
+ DemandRule {
64
+ location: "Neurology",
65
+ start_hour: 14,
66
+ required_skill: NEUROLOGY_NURSE,
67
+ pattern: WeekPattern::Weekdays,
68
+ count: 2,
69
+ },
70
+ DemandRule {
71
+ location: "Neurology",
72
+ start_hour: 6,
73
+ required_skill: NEUROLOGY_DOCTOR,
74
+ pattern: WeekPattern::Weekends,
75
+ count: 1,
76
+ },
77
+ DemandRule {
78
+ location: "Neurology",
79
+ start_hour: 14,
80
+ required_skill: NEUROLOGY_NURSE,
81
+ pattern: WeekPattern::Weekends,
82
+ count: 1,
83
+ },
84
+ DemandRule {
85
+ location: "Neurology",
86
+ start_hour: 22,
87
+ required_skill: CARDIOLOGY,
88
+ pattern: WeekPattern::MonWedFri,
89
+ count: 1,
90
+ },
91
+ DemandRule {
92
+ location: "Critical care",
93
+ start_hour: 6,
94
+ required_skill: CRITICAL_DOCTOR,
95
+ pattern: WeekPattern::Daily,
96
+ count: 2,
97
+ },
98
+ DemandRule {
99
+ location: "Critical care",
100
+ start_hour: 14,
101
+ required_skill: CRITICAL_NURSE,
102
+ pattern: WeekPattern::Daily,
103
+ count: 2,
104
+ },
105
+ DemandRule {
106
+ location: "Critical care",
107
+ start_hour: 22,
108
+ required_skill: CRITICAL_DOCTOR,
109
+ pattern: WeekPattern::Daily,
110
+ count: 1,
111
+ },
112
+ DemandRule {
113
+ location: "Critical care",
114
+ start_hour: 9,
115
+ required_skill: CRITICAL_NURSE,
116
+ pattern: WeekPattern::Weekdays,
117
+ count: 2,
118
+ },
119
+ DemandRule {
120
+ location: "Pediatric care",
121
+ start_hour: 6,
122
+ required_skill: PEDIATRIC_DOCTOR,
123
+ pattern: WeekPattern::Weekdays,
124
+ count: 1,
125
+ },
126
+ DemandRule {
127
+ location: "Pediatric care",
128
+ start_hour: 14,
129
+ required_skill: PEDIATRIC_NURSE,
130
+ pattern: WeekPattern::Weekdays,
131
+ count: 2,
132
+ },
133
+ DemandRule {
134
+ location: "Pediatric care",
135
+ start_hour: 6,
136
+ required_skill: PEDIATRIC_DOCTOR,
137
+ pattern: WeekPattern::Weekends,
138
+ count: 1,
139
+ },
140
+ DemandRule {
141
+ location: "Pediatric care",
142
+ start_hour: 14,
143
+ required_skill: PEDIATRIC_NURSE,
144
+ pattern: WeekPattern::Weekends,
145
+ count: 2,
146
+ },
147
+ DemandRule {
148
+ location: "Surgery",
149
+ start_hour: 6,
150
+ required_skill: SURGERY_DOCTOR,
151
+ pattern: WeekPattern::Weekdays,
152
+ count: 1,
153
+ },
154
+ DemandRule {
155
+ location: "Surgery",
156
+ start_hour: 14,
157
+ required_skill: ANAESTHETICS,
158
+ pattern: WeekPattern::Weekdays,
159
+ count: 2,
160
+ },
161
+ DemandRule {
162
+ location: "Surgery",
163
+ start_hour: 22,
164
+ required_skill: SURGERY_NURSE,
165
+ pattern: WeekPattern::Weekdays,
166
+ count: 1,
167
+ },
168
+ DemandRule {
169
+ location: "Radiology",
170
+ start_hour: 6,
171
+ required_skill: RADIOLOGY_DAY,
172
+ pattern: WeekPattern::Weekdays,
173
+ count: 2,
174
+ },
175
+ DemandRule {
176
+ location: "Radiology",
177
+ start_hour: 9,
178
+ required_skill: RADIOLOGY_DAY,
179
+ pattern: WeekPattern::Weekdays,
180
+ count: 1,
181
+ },
182
+ DemandRule {
183
+ location: "Radiology",
184
+ start_hour: 14,
185
+ required_skill: RADIOLOGY_NURSE,
186
+ pattern: WeekPattern::Weekdays,
187
+ count: 1,
188
+ },
189
+ DemandRule {
190
+ location: "Radiology",
191
+ start_hour: 22,
192
+ required_skill: RADIOLOGY_CALL,
193
+ pattern: WeekPattern::MonWedFri,
194
+ count: 1,
195
+ },
196
+ DemandRule {
197
+ location: "Radiology",
198
+ start_hour: 6,
199
+ required_skill: RADIOLOGY_DAY,
200
+ pattern: WeekPattern::Saturday,
201
+ count: 1,
202
+ },
203
+ DemandRule {
204
+ location: "Radiology",
205
+ start_hour: 9,
206
+ required_skill: RADIOLOGY_DAY,
207
+ pattern: WeekPattern::Saturday,
208
+ count: 1,
209
+ },
210
+ DemandRule {
211
+ location: "Radiology",
212
+ start_hour: 14,
213
+ required_skill: RADIOLOGY_NURSE,
214
+ pattern: WeekPattern::Saturday,
215
+ count: 1,
216
+ },
217
+ DemandRule {
218
+ location: "Radiology",
219
+ start_hour: 6,
220
+ required_skill: RADIOLOGY_DAY,
221
+ pattern: WeekPattern::Sunday,
222
+ count: 1,
223
+ },
224
+ DemandRule {
225
+ location: "Radiology",
226
+ start_hour: 9,
227
+ required_skill: RADIOLOGY_DAY,
228
+ pattern: WeekPattern::Sunday,
229
+ count: 1,
230
+ },
231
+ DemandRule {
232
+ location: "Outpatient",
233
+ start_hour: 6,
234
+ required_skill: OUTPATIENT_NURSE,
235
+ pattern: WeekPattern::Weekdays,
236
+ count: 2,
237
+ },
238
+ DemandRule {
239
+ location: "Outpatient",
240
+ start_hour: 14,
241
+ required_skill: OUTPATIENT_DOCTOR,
242
+ pattern: WeekPattern::Weekdays,
243
+ count: 1,
244
+ },
245
+ ];
246
+
247
+ impl DemandRule {
248
+ /// Expands a rule into the number of shifts it contributes on a specific weekday.
249
+ pub(super) fn count_for_date(&self, weekday: Weekday) -> usize {
250
+ if self.pattern.matches(weekday) {
251
+ self.count
252
+ } else {
253
+ 0
254
+ }
255
+ }
256
+ }
257
+
258
+ impl WeekPattern {
259
+ /// Returns whether the abstract pattern includes the given weekday.
260
+ fn matches(self, weekday: Weekday) -> bool {
261
+ match self {
262
+ WeekPattern::Weekdays => matches!(
263
+ weekday,
264
+ Weekday::Mon | Weekday::Tue | Weekday::Wed | Weekday::Thu | Weekday::Fri
265
+ ),
266
+ WeekPattern::Weekends => matches!(weekday, Weekday::Sat | Weekday::Sun),
267
+ WeekPattern::Daily => true,
268
+ WeekPattern::MonWedFri => matches!(weekday, Weekday::Mon | Weekday::Wed | Weekday::Fri),
269
+ WeekPattern::Saturday => weekday == Weekday::Sat,
270
+ WeekPattern::Sunday => weekday == Weekday::Sun,
271
+ }
272
+ }
273
+ }
src/data/data_seed/employees.rs ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::{Duration, NaiveDate};
2
+ use rand::rngs::StdRng;
3
+ use std::collections::BTreeSet;
4
+
5
+ use crate::domain::{CareHub, Employee};
6
+
7
+ use super::time_utils::generate_name_permutations;
8
+ use super::vocabulary::*;
9
+
10
+ /// Draft workforce record used before we instantiate full `Employee` facts.
11
+ #[derive(Clone)]
12
+ pub(super) struct EmployeeBlueprint {
13
+ pub(super) name: String,
14
+ pub(super) skills: BTreeSet<String>,
15
+ pub(super) home_hub: CareHub,
16
+ pub(super) primary_off_weekday: usize,
17
+ }
18
+
19
+ /// Builds the fixed workforce composition for the public demo dataset.
20
+ pub(super) fn build_employee_blueprints(rng: &mut StdRng) -> Vec<EmployeeBlueprint> {
21
+ let names = generate_name_permutations(rng);
22
+ let mut skill_sets: Vec<Vec<&'static str>> = Vec::with_capacity(EMPLOYEE_COUNT);
23
+
24
+ // The generator used to hand almost every day shift to a generic Doctor or
25
+ // Nurse pool. That made most legal assignments interchangeable and flattened
26
+ // local search almost immediately. The redesign keeps the same workforce
27
+ // size, but assigns each employee to one or two service lines so every
28
+ // shift has a smaller, more meaningful candidate set.
29
+ //
30
+ // We still retain the base DOCTOR/NURSE tags so the witness builder can
31
+ // reason about role families, but public shifts now require service-line
32
+ // skills such as `Critical care doctor` or `Outpatient nurse`.
33
+ push_skill_sets(&mut skill_sets, 4, &[DOCTOR, CRITICAL_DOCTOR]);
34
+ push_skill_sets(
35
+ &mut skill_sets,
36
+ 2,
37
+ &[DOCTOR, CRITICAL_DOCTOR, OUTPATIENT_DOCTOR],
38
+ );
39
+ push_skill_sets(&mut skill_sets, 4, &[DOCTOR, NEUROLOGY_DOCTOR, CARDIOLOGY]);
40
+ push_skill_sets(
41
+ &mut skill_sets,
42
+ 3,
43
+ &[DOCTOR, AMBULATORY_DOCTOR, PEDIATRIC_DOCTOR],
44
+ );
45
+ push_skill_sets(&mut skill_sets, 4, &[DOCTOR, SURGERY_DOCTOR, ANAESTHETICS]);
46
+ push_skill_sets(
47
+ &mut skill_sets,
48
+ 1,
49
+ &[DOCTOR, OUTPATIENT_DOCTOR, AMBULATORY_DOCTOR],
50
+ );
51
+ push_skill_sets(
52
+ &mut skill_sets,
53
+ 4,
54
+ &[DOCTOR, RADIOLOGY_CALL, OUTPATIENT_DOCTOR],
55
+ );
56
+
57
+ push_skill_sets(&mut skill_sets, 5, &[NURSE, CRITICAL_NURSE]);
58
+ push_skill_sets(
59
+ &mut skill_sets,
60
+ 3,
61
+ &[NURSE, CRITICAL_NURSE, OUTPATIENT_NURSE],
62
+ );
63
+ push_skill_sets(
64
+ &mut skill_sets,
65
+ 4,
66
+ &[NURSE, AMBULATORY_NURSE, PEDIATRIC_NURSE],
67
+ );
68
+ push_skill_sets(
69
+ &mut skill_sets,
70
+ 4,
71
+ &[NURSE, NEUROLOGY_NURSE, PEDIATRIC_NURSE],
72
+ );
73
+ push_skill_sets(
74
+ &mut skill_sets,
75
+ 4,
76
+ &[NURSE, SURGERY_NURSE, OUTPATIENT_NURSE],
77
+ );
78
+ push_skill_sets(&mut skill_sets, 4, &[NURSE, RADIOLOGY_DAY, RADIOLOGY_NURSE]);
79
+ push_skill_sets(
80
+ &mut skill_sets,
81
+ 2,
82
+ &[NURSE, RADIOLOGY_DAY, RADIOLOGY_NURSE, ANAESTHETICS],
83
+ );
84
+ push_skill_sets(
85
+ &mut skill_sets,
86
+ 2,
87
+ &[NURSE, AMBULATORY_NURSE, OUTPATIENT_NURSE],
88
+ );
89
+
90
+ assert_eq!(
91
+ skill_sets.len(),
92
+ EMPLOYEE_COUNT,
93
+ "employee blueprint count should match workforce target"
94
+ );
95
+
96
+ skill_sets
97
+ .into_iter()
98
+ .enumerate()
99
+ .map(|(index, skills)| {
100
+ let home_hub = CareHub::infer_from_skills(skills.iter().copied());
101
+ EmployeeBlueprint {
102
+ name: names[index].clone(),
103
+ skills: skills.into_iter().map(str::to_string).collect(),
104
+ home_hub,
105
+ primary_off_weekday: 0,
106
+ }
107
+ })
108
+ .collect()
109
+ }
110
+
111
+ /// Appends `count` identical skill bundles to the blueprint list.
112
+ fn push_skill_sets(target: &mut Vec<Vec<&'static str>>, count: usize, skills: &[&'static str]) {
113
+ for _ in 0..count {
114
+ target.push(skills.to_vec());
115
+ }
116
+ }
117
+
118
+ impl EmployeeBlueprint {
119
+ /// Tiny convenience helper used by balancing heuristics.
120
+ pub(super) fn has_skill(&self, skill: &'static str) -> bool {
121
+ self.skills.contains(skill)
122
+ }
123
+
124
+ /// Counts the specialties that are intentionally scarce in this dataset.
125
+ pub(super) fn specialty_count(&self) -> usize {
126
+ usize::from(self.skills.contains(CARDIOLOGY))
127
+ + usize::from(self.skills.contains(ANAESTHETICS))
128
+ + usize::from(self.skills.contains(RADIOLOGY_CALL))
129
+ + usize::from(self.skills.contains(RADIOLOGY_DAY))
130
+ }
131
+ }
132
+
133
+ /// Turns the blueprints into the actual `Employee` facts published by the app.
134
+ pub(super) fn instantiate_employees(
135
+ blueprints: &[EmployeeBlueprint],
136
+ start_date: NaiveDate,
137
+ ) -> Vec<Employee> {
138
+ let mut employees = Vec::with_capacity(blueprints.len());
139
+ for (index, blueprint) in blueprints.iter().enumerate() {
140
+ let mut employee = Employee::new(index, blueprint.name.clone())
141
+ .with_home_hub(blueprint.home_hub)
142
+ .with_skills(blueprint.skills.iter().map(|skill| skill.as_str()));
143
+ for week in 0..(DAYS_IN_SCHEDULE / 7) {
144
+ let date = start_date + Duration::days(week * 7 + blueprint.primary_off_weekday as i64);
145
+ employee.unavailable_dates.insert(date);
146
+ }
147
+ employees.push(employee);
148
+ }
149
+ employees
150
+ }
src/data/data_seed/entrypoints.rs ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::str::FromStr;
2
+
3
+ use crate::domain::Plan;
4
+
5
+ use super::large::generate_large;
6
+
7
+ /// Public demo-data identifiers exposed through the HTTP API.
8
+ ///
9
+ /// The hospital app currently ships one serious benchmark instance rather than a
10
+ /// menu of toy presets, so the surface stays explicit instead of pretending that
11
+ /// multiple sizes exist when they do not.
12
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
13
+ pub enum DemoData {
14
+ Large,
15
+ }
16
+
17
+ impl FromStr for DemoData {
18
+ type Err = ();
19
+
20
+ /// Parses the case-insensitive demo id exposed over HTTP.
21
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
22
+ match s.to_uppercase().as_str() {
23
+ "LARGE" => Ok(DemoData::Large),
24
+ _ => Err(()),
25
+ }
26
+ }
27
+ }
28
+
29
+ impl DemoData {
30
+ /// Returns the canonical uppercase id used by the HTTP API.
31
+ pub fn as_str(&self) -> &'static str {
32
+ match self {
33
+ DemoData::Large => "LARGE",
34
+ }
35
+ }
36
+ }
37
+
38
+ /// Lists the demo identifiers accepted by `/demo-data/{id}`.
39
+ pub fn list_demo_data() -> Vec<&'static str> {
40
+ vec![DemoData::Large.as_str()]
41
+ }
42
+
43
+ /// Generates the requested demo dataset.
44
+ ///
45
+ /// Dispatch stays here so callers see the supported public variants in one
46
+ /// place, while the dataset assembly itself remains hidden in the per-instance
47
+ /// modules.
48
+ pub fn generate(demo: DemoData) -> Plan {
49
+ match demo {
50
+ DemoData::Large => generate_large(),
51
+ }
52
+ }
src/data/data_seed/large.rs ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::OnceLock;
2
+
3
+ use chrono::NaiveDate;
4
+ use rand::rngs::StdRng;
5
+ use rand::SeedableRng;
6
+
7
+ use crate::domain::Plan;
8
+
9
+ use super::availability::add_extra_unavailability;
10
+ use super::cohorts::assign_primary_off_days;
11
+ use super::employees::{build_employee_blueprints, instantiate_employees};
12
+ use super::preferences::add_preferences;
13
+ use super::shifts::{build_public_shifts, prepare_shifts};
14
+ use super::time_utils::find_next_monday;
15
+ use super::validation::validate_public_dataset;
16
+ use super::witness::build_hidden_witness;
17
+
18
+ /// Materializes the canonical hospital benchmark dataset.
19
+ ///
20
+ /// We cache the built plan because demo data is immutable and deterministic.
21
+ /// Reusing the same constructed instance avoids paying generator cost on every
22
+ /// API request while still returning an owned `Plan` to each caller.
23
+ pub fn generate_large() -> Plan {
24
+ static SCHEDULE: OnceLock<Plan> = OnceLock::new();
25
+ SCHEDULE.get_or_init(build_large_schedule).clone()
26
+ }
27
+
28
+ /// Builds the single published benchmark instance from scratch.
29
+ fn build_large_schedule() -> Plan {
30
+ let mut rng = StdRng::seed_from_u64(0);
31
+ let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
32
+
33
+ // Workforce blueprints are the stable source of truth for skill mix and
34
+ // cohort identity. We shape off-days at the blueprint level so the later
35
+ // instantiated employees inherit the intended coverage structure.
36
+ let mut blueprints = build_employee_blueprints(&mut rng);
37
+ assign_primary_off_days(&mut blueprints);
38
+
39
+ // The public problem is what the solver sees: employees plus currently
40
+ // unassigned shifts. We construct that surface before adding preference
41
+ // pressure so all later shaping is anchored to the real published dataset.
42
+ let mut employees = instantiate_employees(&blueprints, start_date);
43
+ let mut shifts = build_public_shifts(start_date);
44
+ prepare_shifts(&mut shifts);
45
+
46
+ // The witness roster is the generator's internal "known feasible" schedule.
47
+ // We never expose it to the solver. We use it only to shape calendars and
48
+ // preferences so the public problem stays feasible while still containing
49
+ // soft-pressure opportunities that construction does not get for free.
50
+ let witness = build_hidden_witness(&employees, &shifts);
51
+ add_extra_unavailability(&mut employees, &shifts, &witness.employee_touched_dates);
52
+ add_preferences(&mut employees, start_date, &blueprints, &shifts, &witness);
53
+
54
+ // Validation is the last step on purpose: it checks the exact public dataset
55
+ // that the API will serve rather than an earlier intermediate state.
56
+ validate_public_dataset(&employees, &shifts);
57
+
58
+ Plan::new(employees, shifts)
59
+ }
src/data/data_seed/preferences.rs ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Preference shaping for the public dataset.
2
+ //!
3
+ //! The hidden witness gives us a hard-feasible schedule. This module then adds
4
+ //! desired/undesired dates so the public problem contains soft-score movement
5
+ //! without throwing away that feasibility margin.
6
+
7
+ mod exchange;
8
+ mod floor;
9
+ mod support;
10
+ mod top_up;
11
+
12
+ use chrono::NaiveDate;
13
+ use std::collections::{BTreeMap, BTreeSet};
14
+
15
+ use crate::domain::{Employee, Shift};
16
+
17
+ use self::exchange::assign_exchange_preferences;
18
+ use self::floor::ensure_preference_floor;
19
+ use self::top_up::{add_weekend_preference_bias, top_up_preferences};
20
+ use super::coverage::employee_can_cover_shift_without_schedule;
21
+ use super::employees::EmployeeBlueprint;
22
+ use super::vocabulary::{EXCHANGE_MARK_LIMIT_PER_EMPLOYEE, MAX_DESIRED_DATES, MAX_UNDESIRED_DATES};
23
+ use super::witness::WitnessRoster;
24
+
25
+ #[cfg(test)]
26
+ pub(super) fn shift_soft_preference_score(employee: &Employee, shift: &Shift) -> i64 {
27
+ support::shift_soft_preference_score(employee, shift)
28
+ }
29
+
30
+ /// Adds the full preference surface for the public dataset.
31
+ pub(super) fn add_preferences(
32
+ employees: &mut [Employee],
33
+ start_date: NaiveDate,
34
+ blueprints: &[EmployeeBlueprint],
35
+ shifts: &[Shift],
36
+ witness: &WitnessRoster,
37
+ ) {
38
+ let analysis = PreferenceAnalysis::build(employees, shifts, witness);
39
+
40
+ // Phase 1: create direct witness-relative exchange pressure.
41
+ //
42
+ // For a curated subset of shifts we mark the witness holder as disliking the
43
+ // touched date and mark one feasible alternative as preferring it. This makes
44
+ // the hidden witness intentionally non-soft-optimal and, more importantly,
45
+ // creates real one-move improvement opportunities in the public solution
46
+ // space instead of generic weekday-themed noise.
47
+ if let Some(max_exchange_marks_per_employee) = EXCHANGE_MARK_LIMIT_PER_EMPLOYEE {
48
+ assign_exchange_preferences(
49
+ employees,
50
+ blueprints,
51
+ shifts,
52
+ witness,
53
+ &analysis,
54
+ max_exchange_marks_per_employee,
55
+ );
56
+ }
57
+
58
+ // Phase 2: fill the remaining preference volume with stable weekday-themed
59
+ // dates. The pure witness-relative variant created a richer soft surface,
60
+ // but it also made cheapest-insertion too eager to burn hard feasibility.
61
+ // The hybrid shape keeps one explicit exchange signal while leaving the
62
+ // bulk of the dataset in a feasibility-friendly, deterministic pattern.
63
+ top_up_preferences(employees, start_date, blueprints);
64
+
65
+ // Phase 3: add a tiny weekend bias only when the employee is still below the
66
+ // target preference floor. This keeps the designed feasibility margin while
67
+ // still breaking some of the largest weekday-only symmetries.
68
+ add_weekend_preference_bias(employees, start_date, blueprints);
69
+
70
+ ensure_preference_floor(
71
+ employees,
72
+ &witness.employee_touched_dates,
73
+ &analysis.coverable_dates_by_employee,
74
+ &analysis.date_pressure,
75
+ );
76
+
77
+ for employee in employees.iter() {
78
+ assert!(
79
+ employee.desired_dates.len() >= 4 && employee.desired_dates.len() <= MAX_DESIRED_DATES,
80
+ "desired-date volume should stay in the designed band"
81
+ );
82
+ assert!(
83
+ employee.undesired_dates.len() >= 4
84
+ && employee.undesired_dates.len() <= MAX_UNDESIRED_DATES,
85
+ "undesired-date volume should stay in the designed band"
86
+ );
87
+ assert!(
88
+ employee
89
+ .desired_dates
90
+ .iter()
91
+ .all(|date| !employee.undesired_dates.contains(date)),
92
+ "desired and undesired dates must stay disjoint"
93
+ );
94
+ }
95
+ }
96
+
97
+ /// Cached facts shared by the different preference-shaping passes.
98
+ struct PreferenceAnalysis {
99
+ candidate_lists: Vec<Vec<usize>>,
100
+ candidate_counts: Vec<usize>,
101
+ witness_shifts_by_employee: Vec<Vec<usize>>,
102
+ coverable_dates_by_employee: Vec<BTreeSet<NaiveDate>>,
103
+ date_pressure: BTreeMap<NaiveDate, usize>,
104
+ }
105
+
106
+ impl PreferenceAnalysis {
107
+ /// Precomputes the helper views every preference phase needs.
108
+ fn build(employees: &[Employee], shifts: &[Shift], witness: &WitnessRoster) -> Self {
109
+ let candidate_lists = eligible_employees_by_shift(employees, shifts);
110
+ let candidate_counts: Vec<usize> = candidate_lists.iter().map(Vec::len).collect();
111
+ let witness_shifts_by_employee =
112
+ witness_shift_indices_by_employee(&witness.assignments, employees.len());
113
+ let coverable_dates_by_employee =
114
+ coverable_dates_by_employee(&candidate_lists, shifts, employees.len());
115
+ let date_pressure = date_pressure_by_day(shifts, &candidate_counts);
116
+
117
+ Self {
118
+ candidate_lists,
119
+ candidate_counts,
120
+ witness_shifts_by_employee,
121
+ coverable_dates_by_employee,
122
+ date_pressure,
123
+ }
124
+ }
125
+ }
126
+
127
+ /// Lists the legal employees for each public shift before scheduling interactions.
128
+ pub(super) fn eligible_employees_by_shift(
129
+ employees: &[Employee],
130
+ shifts: &[Shift],
131
+ ) -> Vec<Vec<usize>> {
132
+ shifts
133
+ .iter()
134
+ .map(|shift| {
135
+ employees
136
+ .iter()
137
+ .enumerate()
138
+ .filter(|(_, employee)| employee_can_cover_shift_without_schedule(employee, shift))
139
+ .map(|(employee_index, _)| employee_index)
140
+ .collect()
141
+ })
142
+ .collect()
143
+ }
144
+
145
+ /// Reverses witness assignments into "which shifts belong to each employee".
146
+ fn witness_shift_indices_by_employee(
147
+ assignments: &[usize],
148
+ employee_count: usize,
149
+ ) -> Vec<Vec<usize>> {
150
+ let mut shifts_by_employee = vec![Vec::new(); employee_count];
151
+ for (shift_index, &employee_index) in assignments.iter().enumerate() {
152
+ shifts_by_employee[employee_index].push(shift_index);
153
+ }
154
+ shifts_by_employee
155
+ }
156
+
157
+ /// Collects every date an employee could legally cover in the public dataset.
158
+ fn coverable_dates_by_employee(
159
+ candidate_lists: &[Vec<usize>],
160
+ shifts: &[Shift],
161
+ employee_count: usize,
162
+ ) -> Vec<BTreeSet<NaiveDate>> {
163
+ let mut dates_by_employee = vec![BTreeSet::new(); employee_count];
164
+ for (shift_index, candidates) in candidate_lists.iter().enumerate() {
165
+ for &candidate in candidates {
166
+ dates_by_employee[candidate].extend(shifts[shift_index].touched_dates.iter().copied());
167
+ }
168
+ }
169
+ dates_by_employee
170
+ }
171
+
172
+ /// Scores dates by how much soft-pressure they can safely carry.
173
+ fn date_pressure_by_day(
174
+ shifts: &[Shift],
175
+ candidate_counts: &[usize],
176
+ ) -> BTreeMap<NaiveDate, usize> {
177
+ let mut pressure = BTreeMap::new();
178
+ for (shift, &candidate_count) in shifts.iter().zip(candidate_counts.iter()) {
179
+ // The goal here is not to make the hard problem tighter. It is to create
180
+ // lots of feasible reassignment opportunities. We therefore score dates
181
+ // by how many "comfortable" alternatives they carry, not by how scarce
182
+ // they are. Scarce dates belong to feasibility; abundant dates are where
183
+ // soft pressure can live without poisoning construction.
184
+ let shift_pressure = candidate_count.clamp(2, 8) - 1;
185
+ for &date in &shift.touched_dates {
186
+ *pressure.entry(date).or_default() += shift_pressure.max(1);
187
+ }
188
+ }
189
+ pressure
190
+ }
src/data/data_seed/preferences/exchange.rs ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::Timelike;
2
+ use std::cmp::Reverse;
3
+
4
+ use crate::domain::{Employee, Shift};
5
+
6
+ use super::support::{
7
+ can_mark_preference_date, date_pressure_for_shift, mark_preference_date, preferred_shift_date,
8
+ shift_prefers_doctor_family, shift_same_shape, PreferenceKind,
9
+ };
10
+ use super::PreferenceAnalysis;
11
+ use crate::data::data_seed::employees::EmployeeBlueprint;
12
+ use crate::data::data_seed::skills::is_specialty_skill;
13
+ use crate::data::data_seed::vocabulary::DOCTOR;
14
+ use crate::data::data_seed::witness::{shift_priority_rank, WitnessRoster};
15
+
16
+ /// Adds a small number of witness-relative preference swaps.
17
+ ///
18
+ /// This is the sharpest source of local-search signal in the dataset: one
19
+ /// employee is marked as disliking a date while another feasible employee is
20
+ /// marked as preferring it.
21
+ pub(super) fn assign_exchange_preferences(
22
+ employees: &mut [Employee],
23
+ blueprints: &[EmployeeBlueprint],
24
+ shifts: &[Shift],
25
+ witness: &WitnessRoster,
26
+ analysis: &PreferenceAnalysis,
27
+ max_exchange_marks_per_employee: usize,
28
+ ) {
29
+ let mut exchange_marks_by_employee = vec![0usize; employees.len()];
30
+ let mut shift_order: Vec<usize> = (0..shifts.len()).collect();
31
+ shift_order.sort_by_key(|&shift_index| {
32
+ let shift = &shifts[shift_index];
33
+ (
34
+ Reverse(analysis.candidate_counts[shift_index]),
35
+ Reverse(date_pressure_for_shift(shift, &analysis.date_pressure)),
36
+ shift_priority_rank(shift),
37
+ shift.start,
38
+ shift_index,
39
+ )
40
+ });
41
+
42
+ for shift_index in shift_order {
43
+ let shift = &shifts[shift_index];
44
+ if analysis.candidate_counts[shift_index] < 6
45
+ || is_specialty_skill(&shift.required_skill)
46
+ || shift.start.time().hour() == 22
47
+ {
48
+ continue;
49
+ }
50
+ let holder = witness.assignments[shift_index];
51
+ let date = preferred_shift_date(shift, &analysis.date_pressure);
52
+
53
+ let Some(alternative) = analysis.candidate_lists[shift_index]
54
+ .iter()
55
+ .copied()
56
+ .filter(|&candidate| candidate != holder)
57
+ .filter(|&candidate| {
58
+ exchange_marks_by_employee[candidate] < max_exchange_marks_per_employee
59
+ })
60
+ .filter(|&candidate| {
61
+ can_mark_preference_date(&employees[candidate], date, PreferenceKind::Desired)
62
+ })
63
+ .min_by_key(|&candidate| {
64
+ exchange_alternative_key(candidate, shift, shifts, blueprints, witness, analysis)
65
+ })
66
+ else {
67
+ continue;
68
+ };
69
+
70
+ if mark_preference_date(&mut employees[alternative], date, PreferenceKind::Desired) {
71
+ exchange_marks_by_employee[alternative] += 1;
72
+ }
73
+ }
74
+ }
75
+
76
+ /// Lower keys mean "better alternative employee for this exchange mark".
77
+ fn exchange_alternative_key(
78
+ candidate: usize,
79
+ shift: &Shift,
80
+ shifts: &[Shift],
81
+ blueprints: &[EmployeeBlueprint],
82
+ witness: &WitnessRoster,
83
+ analysis: &PreferenceAnalysis,
84
+ ) -> (usize, usize, usize, usize, usize, usize) {
85
+ let date = preferred_shift_date(shift, &analysis.date_pressure);
86
+ let same_date_in_witness =
87
+ usize::from(witness.employee_touched_dates[candidate].contains(&date));
88
+ let same_shape_load = analysis.witness_shifts_by_employee[candidate]
89
+ .iter()
90
+ .filter(|&&other_shift_index| shift_same_shape(shift, &shifts[other_shift_index]))
91
+ .count();
92
+ (
93
+ same_date_in_witness,
94
+ usize::from(
95
+ blueprints[candidate].skills.contains(DOCTOR) != shift_prefers_doctor_family(shift),
96
+ ),
97
+ same_shape_load,
98
+ witness.employee_touched_dates[candidate].len(),
99
+ usize::MAX - *analysis.date_pressure.get(&date).unwrap_or(&0),
100
+ candidate,
101
+ )
102
+ }
src/data/data_seed/preferences/floor.rs ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::NaiveDate;
2
+ use std::cmp::Reverse;
3
+ use std::collections::{BTreeMap, BTreeSet};
4
+
5
+ use crate::domain::Employee;
6
+
7
+ use super::support::{can_mark_preference_date, mark_preference_date, PreferenceKind};
8
+
9
+ /// Ensures every employee ends up with the minimum amount of preference signal.
10
+ pub(super) fn ensure_preference_floor(
11
+ employees: &mut [Employee],
12
+ witness_dates_by_employee: &[BTreeSet<NaiveDate>],
13
+ coverable_dates_by_employee: &[BTreeSet<NaiveDate>],
14
+ date_pressure: &BTreeMap<NaiveDate, usize>,
15
+ ) {
16
+ for employee_index in 0..employees.len() {
17
+ while employees[employee_index].undesired_dates.len() < 4 {
18
+ let candidate = witness_dates_by_employee[employee_index]
19
+ .iter()
20
+ .chain(coverable_dates_by_employee[employee_index].iter())
21
+ .copied()
22
+ .filter(|&date| {
23
+ can_mark_preference_date(
24
+ &employees[employee_index],
25
+ date,
26
+ PreferenceKind::Undesired,
27
+ )
28
+ })
29
+ .max_by_key(|date| (*date_pressure.get(date).unwrap_or(&0), Reverse(*date)));
30
+ let Some(date) = candidate else {
31
+ break;
32
+ };
33
+ let _ = mark_preference_date(
34
+ &mut employees[employee_index],
35
+ date,
36
+ PreferenceKind::Undesired,
37
+ );
38
+ }
39
+
40
+ while employees[employee_index].desired_dates.len() < 4 {
41
+ let candidate = coverable_dates_by_employee[employee_index]
42
+ .iter()
43
+ .copied()
44
+ .filter(|&date| !witness_dates_by_employee[employee_index].contains(&date))
45
+ .filter(|&date| {
46
+ can_mark_preference_date(
47
+ &employees[employee_index],
48
+ date,
49
+ PreferenceKind::Desired,
50
+ )
51
+ })
52
+ .max_by_key(|date| (*date_pressure.get(date).unwrap_or(&0), Reverse(*date)))
53
+ .or_else(|| {
54
+ coverable_dates_by_employee[employee_index]
55
+ .iter()
56
+ .copied()
57
+ .filter(|&date| {
58
+ can_mark_preference_date(
59
+ &employees[employee_index],
60
+ date,
61
+ PreferenceKind::Desired,
62
+ )
63
+ })
64
+ .max_by_key(|date| (*date_pressure.get(date).unwrap_or(&0), Reverse(*date)))
65
+ });
66
+ let Some(date) = candidate else {
67
+ break;
68
+ };
69
+ let _ = mark_preference_date(
70
+ &mut employees[employee_index],
71
+ date,
72
+ PreferenceKind::Desired,
73
+ );
74
+ }
75
+ }
76
+ }
src/data/data_seed/preferences/support.rs ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::{NaiveDate, Timelike};
2
+ use std::cmp::Reverse;
3
+ use std::collections::BTreeMap;
4
+
5
+ use crate::domain::{Employee, Shift};
6
+
7
+ use crate::data::data_seed::skills::is_doctor_family_skill;
8
+ use crate::data::data_seed::vocabulary::{MAX_DESIRED_DATES, MAX_UNDESIRED_DATES};
9
+
10
+ #[derive(Clone, Copy)]
11
+ pub(super) enum PreferenceKind {
12
+ Desired,
13
+ Undesired,
14
+ }
15
+
16
+ /// Returns whether a preference mark can be added without breaking the rules.
17
+ pub(super) fn can_mark_preference_date(
18
+ employee: &Employee,
19
+ date: NaiveDate,
20
+ kind: PreferenceKind,
21
+ ) -> bool {
22
+ if employee.unavailable_dates.contains(&date) {
23
+ return false;
24
+ }
25
+ match kind {
26
+ PreferenceKind::Desired => {
27
+ employee.desired_dates.len() < MAX_DESIRED_DATES
28
+ && !employee.desired_dates.contains(&date)
29
+ && !employee.undesired_dates.contains(&date)
30
+ }
31
+ PreferenceKind::Undesired => {
32
+ employee.undesired_dates.len() < MAX_UNDESIRED_DATES
33
+ && !employee.undesired_dates.contains(&date)
34
+ && !employee.desired_dates.contains(&date)
35
+ }
36
+ }
37
+ }
38
+
39
+ /// Mutates the employee by adding a desired or undesired date when legal.
40
+ pub(super) fn mark_preference_date(
41
+ employee: &mut Employee,
42
+ date: NaiveDate,
43
+ kind: PreferenceKind,
44
+ ) -> bool {
45
+ if !can_mark_preference_date(employee, date, kind) {
46
+ return false;
47
+ }
48
+ match kind {
49
+ PreferenceKind::Desired => employee.desired_dates.insert(date),
50
+ PreferenceKind::Undesired => employee.undesired_dates.insert(date),
51
+ }
52
+ }
53
+
54
+ /// Chooses the most "pressure-carrying" date touched by a shift.
55
+ pub(super) fn preferred_shift_date(
56
+ shift: &Shift,
57
+ date_pressure: &BTreeMap<NaiveDate, usize>,
58
+ ) -> NaiveDate {
59
+ shift
60
+ .touched_dates
61
+ .iter()
62
+ .copied()
63
+ .max_by_key(|date| (*date_pressure.get(date).unwrap_or(&0), Reverse(*date)))
64
+ .expect("shift should touch at least one date")
65
+ }
66
+
67
+ /// Returns the precomputed pressure score for the date chosen above.
68
+ pub(super) fn date_pressure_for_shift(
69
+ shift: &Shift,
70
+ date_pressure: &BTreeMap<NaiveDate, usize>,
71
+ ) -> usize {
72
+ let date = preferred_shift_date(shift, date_pressure);
73
+ *date_pressure
74
+ .get(&date)
75
+ .expect("preferred shift date should have a pressure score")
76
+ }
77
+
78
+ /// Small domain helper used while choosing exchange-preference targets.
79
+ pub(super) fn shift_prefers_doctor_family(shift: &Shift) -> bool {
80
+ is_doctor_family_skill(&shift.required_skill)
81
+ }
82
+
83
+ /// Treats two shifts as the same broad "shape" for preference balancing.
84
+ pub(super) fn shift_same_shape(left: &Shift, right: &Shift) -> bool {
85
+ left.required_skill == right.required_skill
86
+ && (left.start.time().hour() == 22) == (right.start.time().hour() == 22)
87
+ }
88
+
89
+ #[cfg(test)]
90
+ /// Test helper for checking whether a move changes the preference score.
91
+ pub(super) fn shift_soft_preference_score(employee: &Employee, shift: &Shift) -> i64 {
92
+ let desired_matches = employee
93
+ .desired_dates
94
+ .iter()
95
+ .filter(|date| shift.touched_dates.contains(date))
96
+ .count() as i64;
97
+ let undesired_matches = employee
98
+ .undesired_dates
99
+ .iter()
100
+ .filter(|date| shift.touched_dates.contains(date))
101
+ .count() as i64;
102
+ desired_matches - undesired_matches
103
+ }
src/data/data_seed/preferences/top_up.rs ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::{NaiveDate, Weekday};
2
+
3
+ use crate::domain::Employee;
4
+
5
+ use super::support::{can_mark_preference_date, mark_preference_date, PreferenceKind};
6
+ use crate::data::data_seed::employees::EmployeeBlueprint;
7
+ use crate::data::data_seed::time_utils::{choose_weekday_with_four_available_dates, weekday_dates};
8
+ use crate::data::data_seed::vocabulary::{TARGET_DESIRED_DATES, TARGET_UNDESIRED_DATES};
9
+
10
+ /// Fills the remaining preference slots with stable weekday-themed dates.
11
+ pub(super) fn top_up_preferences(
12
+ employees: &mut [Employee],
13
+ start_date: NaiveDate,
14
+ blueprints: &[EmployeeBlueprint],
15
+ ) {
16
+ let dates_by_weekday = weekday_dates(start_date);
17
+
18
+ for (employee_index, employee) in employees.iter_mut().enumerate() {
19
+ let blueprint = &blueprints[employee_index];
20
+ let primary_off = blueprint.primary_off_weekday;
21
+
22
+ let preferred_weekday = choose_weekday_with_four_available_dates(
23
+ &employee.unavailable_dates,
24
+ primary_off,
25
+ (0..7).map(|offset| (primary_off + 2 + offset) % 7),
26
+ );
27
+ let undesired_weekday = choose_weekday_with_four_available_dates(
28
+ &employee.unavailable_dates,
29
+ primary_off,
30
+ (0..7).map(|offset| {
31
+ let candidate = (primary_off + 4 + offset) % 7;
32
+ if candidate == preferred_weekday {
33
+ (candidate + 1) % 7
34
+ } else {
35
+ candidate
36
+ }
37
+ }),
38
+ );
39
+
40
+ for &date in &dates_by_weekday[preferred_weekday] {
41
+ if employee.desired_dates.len() >= TARGET_DESIRED_DATES {
42
+ break;
43
+ }
44
+ let _ = mark_preference_date(employee, date, PreferenceKind::Desired);
45
+ }
46
+
47
+ for &date in &dates_by_weekday[undesired_weekday] {
48
+ if employee.undesired_dates.len() >= TARGET_UNDESIRED_DATES {
49
+ break;
50
+ }
51
+ let _ = mark_preference_date(employee, date, PreferenceKind::Undesired);
52
+ }
53
+ }
54
+ }
55
+
56
+ /// Adds a tiny weekend bias to break some weekday-only symmetry.
57
+ pub(super) fn add_weekend_preference_bias(
58
+ employees: &mut [Employee],
59
+ start_date: NaiveDate,
60
+ blueprints: &[EmployeeBlueprint],
61
+ ) {
62
+ let dates_by_weekday = weekday_dates(start_date);
63
+ let saturdays = &dates_by_weekday[Weekday::Sat.num_days_from_monday() as usize];
64
+ let sundays = &dates_by_weekday[Weekday::Sun.num_days_from_monday() as usize];
65
+
66
+ for employee_index in 0..employees.len() {
67
+ let weekend_mode = (blueprints[employee_index].specialty_count()
68
+ + blueprints[employee_index].primary_off_weekday)
69
+ % 3;
70
+
71
+ match weekend_mode {
72
+ 0 if employees[employee_index].desired_dates.len() < TARGET_DESIRED_DATES => {
73
+ if let Some(date) = saturdays
74
+ .iter()
75
+ .chain(sundays.iter())
76
+ .copied()
77
+ .find(|&date| {
78
+ can_mark_preference_date(
79
+ &employees[employee_index],
80
+ date,
81
+ PreferenceKind::Desired,
82
+ )
83
+ })
84
+ {
85
+ let _ = mark_preference_date(
86
+ &mut employees[employee_index],
87
+ date,
88
+ PreferenceKind::Desired,
89
+ );
90
+ }
91
+ }
92
+ 1 if employees[employee_index].undesired_dates.len() < TARGET_UNDESIRED_DATES => {
93
+ if let Some(date) = saturdays
94
+ .iter()
95
+ .chain(sundays.iter())
96
+ .copied()
97
+ .find(|&date| {
98
+ can_mark_preference_date(
99
+ &employees[employee_index],
100
+ date,
101
+ PreferenceKind::Undesired,
102
+ )
103
+ })
104
+ {
105
+ let _ = mark_preference_date(
106
+ &mut employees[employee_index],
107
+ date,
108
+ PreferenceKind::Undesired,
109
+ );
110
+ }
111
+ }
112
+ _ => {}
113
+ }
114
+ }
115
+ }
src/data/data_seed/shifts.rs ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime};
2
+
3
+ use crate::domain::Shift;
4
+
5
+ use super::demand::DEMAND_RULES;
6
+ use super::time_utils::{dates_touched_by_span, find_next_monday, time};
7
+ use super::vocabulary::DAYS_IN_SCHEDULE;
8
+
9
+ /// Expands the demand template into the actual public shift entities.
10
+ pub(super) fn build_public_shifts(start_date: NaiveDate) -> Vec<Shift> {
11
+ let mut shifts = Vec::with_capacity(expected_shift_count());
12
+ let mut shift_id = 0usize;
13
+
14
+ for day in 0..DAYS_IN_SCHEDULE {
15
+ let date = start_date + Duration::days(day);
16
+ for rule in DEMAND_RULES {
17
+ for _ in 0..rule.count_for_date(date.weekday()) {
18
+ let start = NaiveDateTime::new(date, time(rule.start_hour, 0));
19
+ let end = start + Duration::hours(8);
20
+ shifts.push(Shift::new(
21
+ shift_id.to_string(),
22
+ start,
23
+ end,
24
+ rule.location,
25
+ rule.required_skill,
26
+ ));
27
+ shift_id += 1;
28
+ }
29
+ }
30
+ }
31
+
32
+ shifts
33
+ }
34
+
35
+ /// Fills derived shift fields after the raw templates are materialized.
36
+ pub(super) fn prepare_shifts(shifts: &mut [Shift]) {
37
+ for (index, shift) in shifts.iter_mut().enumerate() {
38
+ shift.index = index;
39
+ shift.touched_dates = dates_touched_by_span(shift.start, shift.end);
40
+ }
41
+ }
42
+
43
+ /// Recomputes the expected number of public shifts from the demand template.
44
+ pub(super) fn expected_shift_count() -> usize {
45
+ let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
46
+ let mut count = 0usize;
47
+ for day in 0..DAYS_IN_SCHEDULE {
48
+ let date = start_date + Duration::days(day);
49
+ for rule in DEMAND_RULES {
50
+ count += rule.count_for_date(date.weekday());
51
+ }
52
+ }
53
+ count
54
+ }
src/data/data_seed/skills.rs ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::vocabulary::*;
2
+
3
+ /// Returns whether the skill is one of the scarcer specialty signals.
4
+ pub(super) fn is_specialty_skill(skill: &str) -> bool {
5
+ matches!(
6
+ skill,
7
+ CARDIOLOGY | ANAESTHETICS | RADIOLOGY_DAY | RADIOLOGY_CALL
8
+ )
9
+ }
10
+
11
+ /// Groups service-line skills under the broad "doctor-family" umbrella.
12
+ pub(super) fn is_doctor_family_skill(skill: &str) -> bool {
13
+ matches!(
14
+ skill,
15
+ DOCTOR
16
+ | AMBULATORY_DOCTOR
17
+ | NEUROLOGY_DOCTOR
18
+ | CRITICAL_DOCTOR
19
+ | PEDIATRIC_DOCTOR
20
+ | SURGERY_DOCTOR
21
+ | OUTPATIENT_DOCTOR
22
+ | RADIOLOGY_CALL
23
+ | CARDIOLOGY
24
+ | ANAESTHETICS
25
+ )
26
+ }
27
+
28
+ /// Groups service-line skills under the broad "nurse-family" umbrella.
29
+ pub(super) fn is_nurse_family_skill(skill: &str) -> bool {
30
+ matches!(
31
+ skill,
32
+ NURSE
33
+ | AMBULATORY_NURSE
34
+ | NEUROLOGY_NURSE
35
+ | CRITICAL_NURSE
36
+ | PEDIATRIC_NURSE
37
+ | SURGERY_NURSE
38
+ | OUTPATIENT_NURSE
39
+ | RADIOLOGY_NURSE
40
+ | RADIOLOGY_DAY
41
+ )
42
+ }
src/data/data_seed/solve_tests.rs ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::Timelike;
2
+ use solverforge::{ConstraintSet, SolverEvent, SolverManager};
3
+ use std::collections::BTreeMap;
4
+
5
+ use super::{generate, DemoData};
6
+ use crate::domain::Plan;
7
+
8
+ // Slow end-to-end acceptance test for the published benchmark instance.
9
+
10
+ fn schedule() -> Plan {
11
+ generate(DemoData::Large)
12
+ }
13
+
14
+ #[test]
15
+ #[ignore = "slow acceptance test for the canonical quickstart dataset"]
16
+ fn large_demo_solves_to_feasible_terminal_state() {
17
+ static MANAGER: SolverManager<Plan> = SolverManager::new();
18
+
19
+ let schedule = schedule();
20
+ let (job_id, mut receiver) = MANAGER.solve(schedule).expect("job should start");
21
+ let mut completed_score = None;
22
+
23
+ while let Some(event) = receiver.blocking_recv() {
24
+ match event {
25
+ SolverEvent::Completed { solution, .. } => {
26
+ completed_score = solution.score;
27
+ if let Some(score) = solution.score {
28
+ if score.hard_score() != solverforge::HardSoftDecimalScore::ZERO {
29
+ let mut mismatches = BTreeMap::<(String, u32, String), usize>::new();
30
+ for shift in &solution.shifts {
31
+ let Some(employee_idx) = shift.employee_idx else {
32
+ continue;
33
+ };
34
+ let employee = &solution.employees[employee_idx];
35
+ if !employee.skills.contains(&shift.required_skill) {
36
+ *mismatches
37
+ .entry((
38
+ shift.location.clone(),
39
+ shift.start.time().hour(),
40
+ shift.required_skill.clone(),
41
+ ))
42
+ .or_default() += 1;
43
+ }
44
+ }
45
+ eprintln!("large demo skill mismatches: {mismatches:?}");
46
+
47
+ let constraints = crate::constraints::create_constraints();
48
+ let analyses = constraints.evaluate_detailed(&solution);
49
+ let hard_breakdown: Vec<_> = analyses
50
+ .into_iter()
51
+ .filter(|analysis| {
52
+ analysis.score.hard_score()
53
+ != solverforge::HardSoftDecimalScore::ZERO
54
+ })
55
+ .map(|analysis| {
56
+ format!("{}={}", analysis.constraint_ref.name, analysis.score)
57
+ })
58
+ .collect();
59
+ eprintln!("large demo hard breakdown: {}", hard_breakdown.join(", "));
60
+ }
61
+ }
62
+ break;
63
+ }
64
+ SolverEvent::Failed { error, .. } => {
65
+ panic!("large demo solve failed unexpectedly: {error}");
66
+ }
67
+ _ => {}
68
+ }
69
+ }
70
+
71
+ let score = completed_score.expect("expected a completed score");
72
+ assert_eq!(score.hard_score(), solverforge::HardSoftDecimalScore::ZERO);
73
+
74
+ MANAGER.delete(job_id).expect("delete completed job");
75
+ }
src/data/data_seed/tests.rs ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::availability::add_extra_unavailability;
2
+ use super::cohorts::assign_primary_off_days;
3
+ use super::coverage::public_candidate_counts;
4
+ use super::demand::DEMAND_RULES;
5
+ use super::employees::{build_employee_blueprints, instantiate_employees};
6
+ use super::preferences::{
7
+ add_preferences, eligible_employees_by_shift, shift_soft_preference_score,
8
+ };
9
+ use super::shifts::{build_public_shifts, prepare_shifts};
10
+ use super::time_utils::find_next_monday;
11
+ use super::vocabulary::*;
12
+ use super::witness::build_hidden_witness;
13
+ use super::*;
14
+ use chrono::{Datelike, Duration, NaiveDate, Timelike};
15
+ use rand::rngs::StdRng;
16
+ use rand::SeedableRng;
17
+ use solverforge::ConstraintSet;
18
+ use std::collections::{BTreeMap, BTreeSet};
19
+
20
+ use crate::domain::Plan;
21
+
22
+ // These tests lock down the generator contract: workforce shape, shift counts,
23
+ // feasibility margins, and the intended soft-score signal surface.
24
+
25
+ fn schedule() -> Plan {
26
+ generate(DemoData::Large)
27
+ }
28
+
29
+ #[test]
30
+ fn test_generate_large() {
31
+ let schedule = schedule();
32
+
33
+ assert_eq!(schedule.employees.len(), 50);
34
+ assert_eq!(schedule.shifts.len(), 688);
35
+ }
36
+
37
+ #[test]
38
+ fn test_exact_workforce_composition() {
39
+ let schedule = schedule();
40
+ let employees = &schedule.employees;
41
+
42
+ let doctors = employees
43
+ .iter()
44
+ .filter(|employee| employee.skills.contains(DOCTOR))
45
+ .count();
46
+ let nurses = employees
47
+ .iter()
48
+ .filter(|employee| employee.skills.contains(NURSE))
49
+ .count();
50
+ let cardiology = employees
51
+ .iter()
52
+ .filter(|employee| employee.skills.contains(CARDIOLOGY))
53
+ .count();
54
+ let anaesthetics = employees
55
+ .iter()
56
+ .filter(|employee| employee.skills.contains(ANAESTHETICS))
57
+ .count();
58
+ let radiology_day = employees
59
+ .iter()
60
+ .filter(|employee| employee.skills.contains(RADIOLOGY_DAY))
61
+ .count();
62
+ let radiology_nurse = employees
63
+ .iter()
64
+ .filter(|employee| employee.skills.contains(RADIOLOGY_NURSE))
65
+ .count();
66
+ let radiology_call = employees
67
+ .iter()
68
+ .filter(|employee| employee.skills.contains(RADIOLOGY_CALL))
69
+ .count();
70
+ let ambulatory_doctors = employees
71
+ .iter()
72
+ .filter(|employee| employee.skills.contains(AMBULATORY_DOCTOR))
73
+ .count();
74
+ let ambulatory_nurses = employees
75
+ .iter()
76
+ .filter(|employee| employee.skills.contains(AMBULATORY_NURSE))
77
+ .count();
78
+ let critical_doctors = employees
79
+ .iter()
80
+ .filter(|employee| employee.skills.contains(CRITICAL_DOCTOR))
81
+ .count();
82
+ let critical_nurses = employees
83
+ .iter()
84
+ .filter(|employee| employee.skills.contains(CRITICAL_NURSE))
85
+ .count();
86
+ let outpatient_doctors = employees
87
+ .iter()
88
+ .filter(|employee| employee.skills.contains(OUTPATIENT_DOCTOR))
89
+ .count();
90
+ let outpatient_nurses = employees
91
+ .iter()
92
+ .filter(|employee| employee.skills.contains(OUTPATIENT_NURSE))
93
+ .count();
94
+
95
+ assert_eq!(doctors, 22);
96
+ assert_eq!(nurses, 28);
97
+ assert_eq!(cardiology, 4);
98
+ assert_eq!(anaesthetics, 6);
99
+ assert_eq!(radiology_day, 6);
100
+ assert_eq!(radiology_nurse, 6);
101
+ assert_eq!(radiology_call, 4);
102
+ assert_eq!(ambulatory_doctors, 4);
103
+ assert_eq!(ambulatory_nurses, 6);
104
+ assert_eq!(critical_doctors, 6);
105
+ assert_eq!(critical_nurses, 8);
106
+ assert_eq!(outpatient_doctors, 7);
107
+ assert_eq!(outpatient_nurses, 9);
108
+ }
109
+
110
+ #[test]
111
+ fn test_exact_shift_template_counts() {
112
+ let schedule = schedule();
113
+ let mut actual = BTreeMap::<(String, u32, String), usize>::new();
114
+ for shift in &schedule.shifts {
115
+ *actual
116
+ .entry((
117
+ shift.location.clone(),
118
+ shift.start.time().hour(),
119
+ shift.required_skill.clone(),
120
+ ))
121
+ .or_default() += 1;
122
+ }
123
+
124
+ let mut expected = BTreeMap::<(String, u32, String), usize>::new();
125
+ let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
126
+ for day in 0..DAYS_IN_SCHEDULE {
127
+ let date = start_date + Duration::days(day);
128
+ for rule in DEMAND_RULES {
129
+ *expected
130
+ .entry((
131
+ rule.location.to_string(),
132
+ rule.start_hour,
133
+ rule.required_skill.to_string(),
134
+ ))
135
+ .or_default() += rule.count_for_date(date.weekday());
136
+ }
137
+ }
138
+
139
+ assert_eq!(actual, expected);
140
+ }
141
+
142
+ #[test]
143
+ fn test_preferences_are_disjoint_from_unavailability() {
144
+ let schedule = schedule();
145
+ for employee in &schedule.employees {
146
+ assert!(employee
147
+ .desired_dates
148
+ .iter()
149
+ .all(|date| !employee.unavailable_dates.contains(date)));
150
+ assert!(employee
151
+ .undesired_dates
152
+ .iter()
153
+ .all(|date| !employee.unavailable_dates.contains(date)));
154
+ assert!((4..=MAX_DESIRED_DATES).contains(&employee.desired_dates.len()));
155
+ assert!((4..=MAX_UNDESIRED_DATES).contains(&employee.undesired_dates.len()));
156
+ assert!(employee
157
+ .desired_dates
158
+ .iter()
159
+ .all(|date| !employee.undesired_dates.contains(date)));
160
+ }
161
+ }
162
+
163
+ #[test]
164
+ fn test_preference_surface_has_one_move_signal() {
165
+ let schedule = schedule();
166
+ let witness = build_hidden_witness(&schedule.employees, &schedule.shifts);
167
+ let candidate_lists = eligible_employees_by_shift(&schedule.employees, &schedule.shifts);
168
+ let signal_shifts = schedule
169
+ .shifts
170
+ .iter()
171
+ .enumerate()
172
+ .filter(|(shift_index, shift)| {
173
+ let holder = witness.assignments[*shift_index];
174
+ let holder_score = shift_soft_preference_score(&schedule.employees[holder], shift);
175
+ candidate_lists[*shift_index]
176
+ .iter()
177
+ .copied()
178
+ .filter(|&candidate| candidate != holder)
179
+ .any(|candidate| {
180
+ let candidate_score =
181
+ shift_soft_preference_score(&schedule.employees[candidate], shift);
182
+ candidate_score - holder_score >= 2
183
+ })
184
+ })
185
+ .count();
186
+
187
+ assert!(
188
+ signal_shifts > 0,
189
+ "there should still be some one-move soft improvements"
190
+ );
191
+ }
192
+
193
+ #[test]
194
+ fn test_public_candidate_redundancy() {
195
+ let schedule = schedule();
196
+ let counts = public_candidate_counts(&schedule.employees, &schedule.shifts);
197
+ assert!(counts.iter().all(|&count| count >= 2));
198
+ assert!(counts.iter().filter(|&&count| count >= 3).count() * 4 >= counts.len());
199
+ }
200
+
201
+ #[test]
202
+ fn test_candidate_width_is_not_generic_role_wide() {
203
+ let schedule = schedule();
204
+ let mut counts = public_candidate_counts(&schedule.employees, &schedule.shifts);
205
+ counts.sort_unstable();
206
+ let median = counts[counts.len() / 2];
207
+ let p90 = counts[counts.len() * 9 / 10];
208
+
209
+ assert!(median <= 7, "median candidate count should stay narrow");
210
+ assert!(
211
+ p90 <= 9,
212
+ "90th percentile candidate count should stay bounded"
213
+ );
214
+ }
215
+
216
+ #[test]
217
+ fn test_hidden_witness_is_hard_feasible() {
218
+ let mut rng = StdRng::seed_from_u64(0);
219
+ let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
220
+ let mut blueprints = build_employee_blueprints(&mut rng);
221
+ assign_primary_off_days(&mut blueprints);
222
+ let mut employees = instantiate_employees(&blueprints, start_date);
223
+ let mut shifts = build_public_shifts(start_date);
224
+ prepare_shifts(&mut shifts);
225
+
226
+ let witness = build_hidden_witness(&employees, &shifts);
227
+ add_extra_unavailability(&mut employees, &shifts, &witness.employee_touched_dates);
228
+ add_preferences(&mut employees, start_date, &blueprints, &shifts, &witness);
229
+
230
+ for (shift, employee_idx) in shifts.iter_mut().zip(witness.assignments.iter()) {
231
+ shift.employee_idx = Some(*employee_idx);
232
+ }
233
+
234
+ let witness_schedule = Plan::new(employees, shifts);
235
+ let score = crate::constraints::create_constraints().evaluate_all(&witness_schedule);
236
+ assert_eq!(score.hard_score(), solverforge::HardSoftDecimalScore::ZERO);
237
+ }
238
+
239
+ #[test]
240
+ fn test_employees_have_skills() {
241
+ let schedule = schedule();
242
+
243
+ for employee in &schedule.employees {
244
+ assert!(
245
+ !employee.skills.is_empty(),
246
+ "Employee {} has no skills",
247
+ employee.name
248
+ );
249
+ }
250
+ }
251
+
252
+ #[test]
253
+ fn test_demo_data_from_str() {
254
+ assert_eq!("LARGE".parse::<DemoData>(), Ok(DemoData::Large));
255
+ assert_eq!("large".parse::<DemoData>(), Ok(DemoData::Large));
256
+ assert!("invalid".parse::<DemoData>().is_err());
257
+ }
258
+
259
+ #[test]
260
+ fn test_medical_domain() {
261
+ let schedule = schedule();
262
+
263
+ let all_skills: BTreeSet<_> = schedule
264
+ .employees
265
+ .iter()
266
+ .flat_map(|employee| employee.skills.iter())
267
+ .map(|skill| skill.as_str())
268
+ .collect();
269
+
270
+ assert!(all_skills.contains(DOCTOR) || all_skills.contains(NURSE));
271
+ let locations: BTreeSet<_> = schedule
272
+ .shifts
273
+ .iter()
274
+ .map(|shift| shift.location.as_str())
275
+ .collect();
276
+ assert!(locations.contains("Ambulatory care") || locations.contains("Critical care"));
277
+ }
278
+
279
+ #[test]
280
+ fn test_empty_schedule_has_score() {
281
+ let schedule = crate::domain::Plan::new(vec![], vec![]);
282
+ let score = crate::constraints::create_constraints().evaluate_all(&schedule);
283
+ assert_eq!(score.to_string(), "0hard/0soft");
284
+ }
src/data/data_seed/time_utils.rs ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Weekday};
2
+ use rand::prelude::*;
3
+ use rand::rngs::StdRng;
4
+ use std::collections::BTreeSet;
5
+
6
+ use crate::domain::Shift;
7
+
8
+ use super::vocabulary::{DAYS_IN_SCHEDULE, FIRST_NAMES, LAST_NAMES};
9
+
10
+ /// Returns the sorted set of dates touched by the published shifts.
11
+ pub(super) fn horizon_dates(shifts: &[Shift]) -> Vec<NaiveDate> {
12
+ let mut dates = BTreeSet::new();
13
+ for shift in shifts {
14
+ for &date in &shift.touched_dates {
15
+ dates.insert(date);
16
+ }
17
+ }
18
+ dates.into_iter().collect()
19
+ }
20
+
21
+ /// Buckets every schedule date by weekday for preference shaping.
22
+ pub(super) fn weekday_dates(start_date: NaiveDate) -> [Vec<NaiveDate>; 7] {
23
+ let mut dates = std::array::from_fn(|_| Vec::new());
24
+ for day in 0..DAYS_IN_SCHEDULE {
25
+ let date = start_date + Duration::days(day);
26
+ dates[date.weekday().num_days_from_monday() as usize].push(date);
27
+ }
28
+ dates
29
+ }
30
+
31
+ /// Finds a weekday that still exposes four available dates after unavailability.
32
+ pub(super) fn choose_weekday_with_four_available_dates(
33
+ unavailable_dates: &BTreeSet<NaiveDate>,
34
+ primary_off: usize,
35
+ candidates: impl IntoIterator<Item = usize>,
36
+ ) -> usize {
37
+ candidates
38
+ .into_iter()
39
+ .filter(|&weekday| weekday != primary_off)
40
+ .find(|&weekday| {
41
+ weekday_dates(find_next_monday(
42
+ NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
43
+ ))[weekday]
44
+ .iter()
45
+ .filter(|date| !unavailable_dates.contains(date))
46
+ .count()
47
+ == 4
48
+ })
49
+ .expect("weekday with four available dates should exist")
50
+ }
51
+
52
+ /// Expands a time span into the calendar dates it touches.
53
+ pub(super) fn dates_touched_by_span(start: NaiveDateTime, end: NaiveDateTime) -> Vec<NaiveDate> {
54
+ let mut touched_dates = Vec::new();
55
+ let mut date = start.date();
56
+
57
+ while date <= end.date() {
58
+ if overlap_minutes_for_day(start, end, date) > 0 {
59
+ touched_dates.push(date);
60
+ }
61
+
62
+ let Some(next_date) = date.succ_opt() else {
63
+ break;
64
+ };
65
+ date = next_date;
66
+ }
67
+
68
+ touched_dates
69
+ }
70
+
71
+ /// Measures how many minutes of the span overlap one specific date.
72
+ fn overlap_minutes_for_day(start: NaiveDateTime, end: NaiveDateTime, date: NaiveDate) -> i64 {
73
+ let day_start = date.and_hms_opt(0, 0, 0).unwrap();
74
+ let day_end = date
75
+ .succ_opt()
76
+ .unwrap_or(date)
77
+ .and_hms_opt(0, 0, 0)
78
+ .unwrap();
79
+
80
+ let overlap_start = start.max(day_start);
81
+ let overlap_end = end.min(day_end);
82
+
83
+ if overlap_start < overlap_end {
84
+ (overlap_end - overlap_start).num_minutes()
85
+ } else {
86
+ 0
87
+ }
88
+ }
89
+
90
+ /// Creates a deterministic shuffled name list for the workforce generator.
91
+ pub(super) fn generate_name_permutations(rng: &mut StdRng) -> Vec<String> {
92
+ let mut names = Vec::with_capacity(FIRST_NAMES.len() * LAST_NAMES.len());
93
+ for first in FIRST_NAMES {
94
+ for last in LAST_NAMES {
95
+ names.push(format!("{first} {last}"));
96
+ }
97
+ }
98
+ names.shuffle(rng);
99
+ names
100
+ }
101
+
102
+ /// Small helper so shift templates can read as `time(14, 0)`.
103
+ pub(super) fn time(hour: u32, minute: u32) -> NaiveTime {
104
+ NaiveTime::from_hms_opt(hour, minute, 0).unwrap()
105
+ }
106
+
107
+ /// Anchors the benchmark to a Monday so weekday-based rules stay stable.
108
+ pub(super) fn find_next_monday(date: NaiveDate) -> NaiveDate {
109
+ let days_until_monday = match date.weekday() {
110
+ Weekday::Mon => 0,
111
+ Weekday::Tue => 6,
112
+ Weekday::Wed => 5,
113
+ Weekday::Thu => 4,
114
+ Weekday::Fri => 3,
115
+ Weekday::Sat => 2,
116
+ Weekday::Sun => 1,
117
+ };
118
+ date + Duration::days(days_until_monday)
119
+ }
src/data/data_seed/validation.rs ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::Timelike;
2
+
3
+ use crate::domain::{Employee, Shift};
4
+
5
+ use super::coverage::{candidate_redundancy_is_valid, public_candidate_counts};
6
+
7
+ /// Verifies that the exact public dataset we will ship still matches generator goals.
8
+ pub(super) fn validate_public_dataset(employees: &[Employee], shifts: &[Shift]) {
9
+ let counts = public_candidate_counts(employees, shifts);
10
+ let min_count = counts.iter().copied().min().unwrap_or(0);
11
+ let three_plus = counts.iter().filter(|&&count| count >= 3).count();
12
+ let weakest: Vec<String> = shifts
13
+ .iter()
14
+ .zip(counts.iter())
15
+ .filter(|(_, &count)| count == min_count)
16
+ .take(5)
17
+ .map(|(shift, &count)| {
18
+ format!(
19
+ "{} {} {} -> {}",
20
+ shift.location,
21
+ shift.start.time().hour(),
22
+ shift.required_skill,
23
+ count
24
+ )
25
+ })
26
+ .collect();
27
+ assert!(
28
+ candidate_redundancy_is_valid(employees, shifts),
29
+ "public dataset should maintain candidate redundancy; min_count={min_count}, three_plus={three_plus}/{} weakest={weakest:?}",
30
+ counts.len()
31
+ );
32
+ }