Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ·
ea74628
0
Parent(s):
chore: sync uc-hospital Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- .gitignore +27 -0
- .pre-commit-config.yaml +21 -0
- AGENTS.md +112 -0
- CHANGELOG.md +69 -0
- Cargo.lock +1628 -0
- Cargo.toml +31 -0
- Dockerfile +40 -0
- Makefile +314 -0
- README.md +212 -0
- WIREFRAME.md +300 -0
- docs/api-and-solver-policy.md +90 -0
- docs/screenshot.png +3 -0
- solver.toml +43 -0
- solverforge.app.toml +83 -0
- src/api/dto.rs +259 -0
- src/api/dto/tests.rs +146 -0
- src/api/mod.rs +13 -0
- src/api/routes.rs +225 -0
- src/api/routes/tests.rs +239 -0
- src/api/sse.rs +74 -0
- src/constraints/assigned_shift.rs +14 -0
- src/constraints/balance_assignments.rs +12 -0
- src/constraints/desired_day.rs +33 -0
- src/constraints/minimum_rest.rs +38 -0
- src/constraints/mod.rs +43 -0
- src/constraints/one_shift_per_day.rs +21 -0
- src/constraints/overlapping_shift.rs +28 -0
- src/constraints/required_skill.rs +24 -0
- src/constraints/unavailable_employee.rs +58 -0
- src/constraints/undesired_day.rs +33 -0
- src/data/data_seed.rs +28 -0
- src/data/data_seed/availability.rs +72 -0
- src/data/data_seed/cohorts.rs +198 -0
- src/data/data_seed/coverage.rs +47 -0
- src/data/data_seed/demand.rs +273 -0
- src/data/data_seed/employees.rs +150 -0
- src/data/data_seed/entrypoints.rs +52 -0
- src/data/data_seed/large.rs +59 -0
- src/data/data_seed/preferences.rs +190 -0
- src/data/data_seed/preferences/exchange.rs +102 -0
- src/data/data_seed/preferences/floor.rs +76 -0
- src/data/data_seed/preferences/support.rs +103 -0
- src/data/data_seed/preferences/top_up.rs +115 -0
- src/data/data_seed/shifts.rs +54 -0
- src/data/data_seed/skills.rs +42 -0
- src/data/data_seed/solve_tests.rs +75 -0
- src/data/data_seed/tests.rs +284 -0
- src/data/data_seed/time_utils.rs +119 -0
- 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 |
+

|
| 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
|
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 |
+
}
|