diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..46432e4563e47e050e1a3b98453ec9d28ee1eaf5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docs/screenshot.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ca3d92b3f253e9861e9d0629de0a5f7c00875635 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ +test-results/ +playwright-report/ + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +PRD.md + +# OSM routing cache +.osm_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cd8ff362975066b6c060d14ac0e9609d67d41458 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-merge-conflict + - id: check-added-large-files + +- repo: https://github.com/gitleaks/gitleaks + rev: v8.18.0 + hooks: + - id: gitleaks +- repo: https://github.com/doublify/pre-commit-rust + rev: v1.0 + hooks: + - id: fmt + args: ["--", "--check"] + - id: clippy + args: ["--", "-D", "warnings"] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..6a2c2a3ffcb412f241111c030cf6563e27f6c3f6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,112 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +`src/` follows the current `solverforge-cli` app shape: `api/` for HTTP +routes, SSE, and DTOs; `solver/` for retained-job orchestration; `domain/mod.rs` +for the current `solverforge::planning_model!` manifest; `domain/` for the +exported model modules; `constraints/mod.rs` for constraint assembly; and +`constraints/*.rs` for the individual score rules. `data/mod.rs` is the stable +wrapper; `data/data_seed.rs` is the thin public data surface; and +`data/data_seed/` holds the deterministic sample dataset modules, including the +public entrypoints and the `LARGE` instance builder. `domain/plan.rs` owns +`Plan`, the scalar-variable `Shift`, and the scalar nearby hook functions; +`domain/employee.rs` and `domain/care_hub.rs` hold supporting domain types. +`static/` holds the browser app (`static/app/**/*.mjs`) and generated UI config. +Frontend tests live in `tests/frontend/`. Container packaging is defined by +`Dockerfile`. + +This project depends on the published `solverforge` and `solverforge-ui` crates. + +## Build, Test, and Development Commands + +- `make help` — show the supported local development and validation commands. +- `make run-release` — run the app locally on `:7860`. +- `make test` — run the standard Rust, frontend, and Playwright validation surface. +- `make test-e2e` — run the real browser Playwright smoke. +- `make ci-local` — run the Space-oriented local CI pipeline: fmt, clippy, + release build, standard tests, and Docker image build. +- `make test-slow` — run the ignored large-demo acceptance solve. +- `make pre-release` — run `make ci-local` plus the slow acceptance solve. +- `make space-build` — build the Docker image used by the Docker-based Space + deployment path. +- `cargo run --release --bin solverforge-hospital` — run the app locally on + `:7860`. +- `cargo test` — run Rust unit and integration tests. +- `cargo test large_demo_solves_to_feasible_terminal_state -- --ignored --nocapture` + — slow end-to-end solver acceptance test. +- `find static/app -name '*.mjs' -print0 | xargs -0 -n1 node --check` — + syntax-check frontend modules. +- `node --test tests/frontend/*.test.js` — run browserless frontend tests. +- `docker build -f Dockerfile -t solverforge-hospital .` — build the image from + the repository root context. + +## Coding Style & Naming Conventions + +Use Rust 2021 style with `cargo fmt`; keep imports and formatting +rustfmt-compatible. Prefer small, explicit functions over clever indirection. +Rust module and file names are `snake_case`; types are `UpperCamelCase`; tests +should describe behavior plainly. Frontend modules are plain ES modules in +`snake-case` filenames. Keep generator logic deterministic: do not introduce +random behavior without a fixed seed and an explicit reason. + +## Documentation And Commenting Policy + +Assume a beginner reader who is new to SolverForge and new to optimization +modeling. + +- Treat `README.md`, `WIREFRAME.md`, this file, + `docs/api-and-solver-policy.md`, `docs/screenshot.png`, + `solver.toml` comments, and the visible API help in + `static/app/shell/api-guide.mjs` as one canonical documentation surface. When + one changes, audit the others that describe the same behavior. +- Add module-level docs or comments for every new module that explain its role + in the app and where it sits in the data flow. +- Add function comments when the function does real coordination work, rebuilds + invariants, shapes demo data, converts between layers, or otherwise does + something a beginner would not infer immediately from the signature. +- Write comments that explain intent, domain meaning, invariants, and runtime + consequences. Do not write comments that merely restate syntax. +- Keep comments truthful. If behavior changes, update or delete the stale + comment in the same patch. +- When docs mention versions, counts, routes, solver policy, or validation + expectations, verify those facts against the current code and tests in the + same turn. +- Prefer present-tense current-state docs after a refactor lands. Do not leave + future-tense planning language in repo docs unless the file is intentionally a + still-pending plan. +- When onboarding surfaces change, keep `README.md`, `WIREFRAME.md`, and this + file aligned. + +The standard to aim for is: a new reader should be able to understand why a +piece of code exists before they need to understand every line of how it works. + +The bundle-level `.github/workflows/ci.yml` installs browser dependencies and +runs the root `make ci-local`, which dispatches this app's standard checks. The +app Makefile remains the authoritative standalone and Hugging Face Space +validation surface, especially `make ci-local` and `make pre-release`. + +## Testing Guidelines + +Add Rust tests next to the behavior they protect, usually in `src/...` +`#[cfg(test)]` modules. Frontend behavior belongs in `tests/frontend/` and +should use the existing fake DOM support in `tests/support/`; real browser +flows belong in `tests/e2e/`. If you change solver behavior, run both +`cargo test` and the ignored large-demo solve. If you change UI modules, run +the Node syntax check, frontend tests, and Playwright tests. + +## Commit & Pull Request Guidelines + +Follow the workspace commit style seen upstream: conventional prefixes such as +`fix(...)`, `feat(...)`, `refactor(...)`, `test(...)`, and `chore(...)` (for +example, `fix(runtime): route pure scalar construction to descriptor path`). +PRs should state user-visible impact, changed config or API surface, and the +exact validation commands run. Include screenshots only for visible UI changes. + +## Configuration & Runtime Notes + +`solver.toml` is embedded from `src/domain/plan.rs` via +`#[planning_solution(..., solver_toml = "../../solver.toml")]`; treat it as +the runtime source of truth. Keep `solverforge.app.toml`, +`static/sf-config.json`, and Docker/runtime port settings aligned with any port +or route changes. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..0061f60d62032df90b96777f53813b9c2e86c418 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +All notable changes to this use case are documented in this file. + +## 2.0.6 (2026-07-29) + +### Maintenance + +* **release:** target SolverForge 0.19.3. + +## 2.0.5 (2026-07-17) + + +### Bug Fixes + +* **hospital:** target SolverForge 0.19.0 88f38bd + +## 2.0.4 (2026-07-13) + +### Maintenance + +* **release:** target SolverForge 0.18.0. +* **tests:** align zero-work retained-job coverage with the unified solver lifecycle. +* **docs:** align CI, browser boot, event payload, and solver-policy descriptions with code. +* **metadata:** use the canonical uppercase `LARGE` demo id. + +## 2.0.3 (2026-06-16) + +### Maintenance + +* **release:** target SolverForge 0.17.1 and solverforge-cli 2.2.2. +* **metadata:** publish the hospital facts, entities, variables, and constraints in app metadata. + +## 2.0.2 (2026-05-28) + +### Maintenance + +* **release:** target SolverForge 0.15.0. + +## 2.0.1 (2026-05-16) + +### Maintenance + +* **release:** target SolverForge 0.14.1. + +## 2.0.0 (2026-05-14) + +### Maintenance + +* **release:** set the public app release line to 2.0.0 across Cargo metadata and release validation. + +## 1.0.2 (2026-05-14) + +### Maintenance + +* **release:** align the bundled app with SolverForge 0.13.1 and solverforge-ui 0.6.5. +* **docs:** standardize the use-case README and add beginner-facing SolverForge maintenance notes. + +## 1.0.1 (2026-04-26) + + +### Features + +* **app:** add hospital scheduling application b7e7f16 + + +### Bug Fixes + +* **space:** satisfy Hugging Face metadata validation 470faf8 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..9050a41c150e15ab5333cf03766e19b85693f9ad --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1628 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "solverforge" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d0a5dfb480a56ec3ee9aa048fd54bba3e65b04866bde610c1a956b89b4da80" +dependencies = [ + "solverforge-bridge", + "solverforge-config", + "solverforge-console", + "solverforge-core", + "solverforge-cvrp", + "solverforge-macros", + "solverforge-scoring", + "solverforge-solver", +] + +[[package]] +name = "solverforge-bridge" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c78f964f79318c3c0ee3161ed3004c708f04c4a751f784fe7d0ccc908194aaa3" +dependencies = [ + "solverforge-config", + "solverforge-core", + "solverforge-scoring", + "solverforge-solver", +] + +[[package]] +name = "solverforge-config" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d4cb13eb5097514c5d18eeddb0f88521e3f3d8d402a0d22ffbbdcd08c90c21" +dependencies = [ + "serde", + "serde_yaml", + "solverforge-core", + "thiserror", + "toml", +] + +[[package]] +name = "solverforge-console" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f64e369266512f53d2bd335592975193fdc4d8c2d6da99929f152edfd721562" +dependencies = [ + "num-format", + "owo-colors", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "solverforge-core" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6118e7d98c3c8ef58646b2779603dd1ab6f9dc9f25481cb104402cbcd06be46" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "solverforge-cvrp" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554c12afb5908f396833688be18e649eee5967df8c41c0e4f20f0a10445ff651" +dependencies = [ + "solverforge-solver", +] + +[[package]] +name = "solverforge-hospital" +version = "2.0.6" +dependencies = [ + "axum", + "chrono", + "parking_lot", + "rand", + "serde", + "serde_json", + "solverforge", + "solverforge-ui", + "tokio", + "tokio-stream", + "tower", + "tower-http", +] + +[[package]] +name = "solverforge-macros" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22c15f305806b185fc4815f8da0ec73acb9acbba5de3583433ab7b6bb2eeffa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "solverforge-scoring" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ddf9f9af52b0d2525f581f921e41fac8352dc2f6f1deb7f9100aff9cfedba25" +dependencies = [ + "solverforge-core", + "thiserror", +] + +[[package]] +name = "solverforge-solver" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d30aece798a48d08edc630734a181dd2d9bcc1e806de7ec27701f9885989a6" +dependencies = [ + "rand", + "rand_chacha", + "rayon", + "serde", + "smallvec", + "solverforge-config", + "solverforge-core", + "solverforge-scoring", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "solverforge-ui" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7fa2d78c84af9a1e264adcffc1bdf8cb4edab8d73a3543fb448d166c95596f" +dependencies = [ + "axum", + "include_dir", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..ae1f41f8178f13a76acea43a531d72ed4e9d38a6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "solverforge-hospital" +version = "2.0.6" +edition = "2021" +rust-version = "1.95" +description = "SolverForge hospital scheduling example" +publish = false + +[dependencies] +solverforge = { version = "0.19.3", features = [ + "serde", + "console", + "verbose-logging", +] } +solverforge-ui = "0.6.5" +rand = "0.10.1" + +axum = "0.8.9" +tokio = { version = "1.52.3", features = ["full"] } +tokio-stream = { version = "0.1.18", features = ["sync"] } +tower-http = { version = "0.6.10", features = ["fs", "cors"] } +tower = "0.5.3" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +chrono = { version = "0.4.44", features = ["serde"] } +parking_lot = "0.12.5" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..956467dce8070910bee414ca61f051cd6c4fbdcb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Multi-stage build for solverforge-hospital. + +FROM rust:1.95-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache musl-dev + +WORKDIR /build + +COPY Cargo.toml Cargo.lock ./ +COPY src/ ./src/ +COPY static/ ./static/ +COPY solver.toml ./solver.toml + +# Build release binary with musl target for static linking +RUN cargo build --release --target x86_64-unknown-linux-musl + +# Runtime stage - minimal Alpine image +FROM alpine:latest + +RUN apk add --no-cache ca-certificates + +WORKDIR /app + +# Copy binary from builder (musl static binary) +COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/solverforge-hospital ./solverforge-hospital + +# Copy static files +COPY --from=builder /build/static/ ./static/ + +# Copy solver config +COPY --from=builder /build/solver.toml ./solver.toml + +ENV PORT=7860 + +# Expose the same port the container binds to by default. +EXPOSE 7860 + +# Run the application +CMD ["./solverforge-hospital"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..01223523a1a4ce50c7538fc26d4c193daf84cf13 --- /dev/null +++ b/Makefile @@ -0,0 +1,314 @@ +# SolverForge Hospital Makefile +# Rust + frontend + Space-oriented local build system +# +# This app is primarily validated for local development and Docker-based +# Hugging Face Space deployment. `ci-local` therefore simulates the checks we +# expect before updating a Space, rather than mirroring a GitHub Actions file. + +SHELL := /bin/sh +.SHELLFLAGS := -eu -c +unexport BASH_FUNC_mc%% + +# ============== Colors & Symbols ============== +GREEN := \033[92m +EMERALD := \033[38;2;16;185;129m +CYAN := \033[96m +YELLOW := \033[93m +MAGENTA := \033[95m +RED := \033[91m +GRAY := \033[90m +BOLD := \033[1m +RESET := \033[0m + +CHECK := ✓ +CROSS := ✗ +ARROW := ▸ +PROGRESS := → + +# ============== Project Metadata ============== +APP_NAME := solverforge-hospital +PACKAGE_NAME := solverforge-hospital +VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1) +RELEASE_TAG := $(PACKAGE_NAME)@$(VERSION) +RUST_VERSION := 1.95+ +PORT ?= 7860 +DOCKER_IMAGE ?= $(APP_NAME) +DOCKER_CONTEXT ?= . +DOCKERFILE_PATH := Dockerfile +PLAYWRIGHT ?= ../node_modules/.bin/playwright + +# ============== Phony Targets ============== +.PHONY: banner help doctor build build-release run run-release test test-rust \ + test-frontend-syntax test-frontend test-e2e test-slow test-one lint fmt fmt-check \ + clippy check ci-local space-ci space-build space-run docker-build \ + docker-run pre-release release-ci release-info version clean watch require-node require-docker \ + +# ============== Default Target ============== +.DEFAULT_GOAL := help + +# ============== Banner ============== +banner: + @printf "$(EMERALD)$(BOLD) ____ _ _____\n" + @printf " / ___| ___ | |_ _____ _ __| ___|__ _ __ __ _ ___\n" + @printf " \\___ \\\\ / _ \\\\| \\\\ \\\\ / / _ \\\\ '__| |_ / _ \\\\| '__/ _\` |/ _ \\\\\n" + @printf " ___) | (_) | |\\\\ V / __/ | | _| (_) | | | (_| | __/\n" + @printf " |____/ \\\\___/|_| \\_/ \\___|_| |_| \\___/|_| \\__, |\\___|\n" + @printf " |___/$(RESET)\n" + @printf " $(GRAY)v$(VERSION)$(RESET) $(EMERALD)Hospital demo build system$(RESET)\n\n" + +# ============== Environment Checks ============== + +require-node: + @command -v node >/dev/null 2>&1 || (printf "$(RED)$(CROSS) node is required for frontend validation$(RESET)\n" && exit 1) + +require-docker: + @command -v docker >/dev/null 2>&1 || (printf "$(RED)$(CROSS) docker is required for Space/Docker targets$(RESET)\n" && exit 1) + +doctor: banner + @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n" + @printf "$(CYAN)$(BOLD)║ Environment Check ║$(RESET)\n" + @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n" + @missing=0; \ + if command -v cargo >/dev/null 2>&1; then \ + printf "$(GREEN)$(CHECK) cargo: $$(cargo --version)$(RESET)\n"; \ + else \ + printf "$(RED)$(CROSS) cargo not found$(RESET)\n"; \ + missing=1; \ + fi; \ + if command -v rustc >/dev/null 2>&1; then \ + printf "$(GREEN)$(CHECK) rustc: $$(rustc --version)$(RESET)\n"; \ + else \ + printf "$(RED)$(CROSS) rustc not found$(RESET)\n"; \ + missing=1; \ + fi; \ + if command -v node >/dev/null 2>&1; then \ + printf "$(GREEN)$(CHECK) node: $$(node --version)$(RESET)\n"; \ + else \ + printf "$(RED)$(CROSS) node not found$(RESET)\n"; \ + missing=1; \ + fi; \ + if command -v docker >/dev/null 2>&1; then \ + printf "$(GREEN)$(CHECK) docker: $$(docker --version)$(RESET)\n"; \ + else \ + printf "$(YELLOW)! docker not found; Space/Docker targets will be unavailable$(RESET)\n"; \ + fi; \ + printf "$(GRAY)Docker build context: $(DOCKER_CONTEXT)$(RESET)\n"; \ + printf "$(GRAY)Default app port: $(PORT)$(RESET)\n"; \ + if [ $$missing -ne 0 ]; then exit 1; fi + @printf "\n" + +# ============== Build & Run ============== + +build: banner + @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n" + @printf "$(CYAN)$(BOLD)║ Debug Build ║$(RESET)\n" + @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n" + @printf "$(ARROW) $(BOLD)Building $(APP_NAME)...$(RESET)\n" + @cargo build --bin $(APP_NAME) && \ + printf "$(GREEN)$(CHECK) Debug build successful$(RESET)\n\n" || \ + (printf "$(RED)$(CROSS) Debug build failed$(RESET)\n\n" && exit 1) + +build-release: banner + @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n" + @printf "$(CYAN)$(BOLD)║ Release Build ║$(RESET)\n" + @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n" + @printf "$(ARROW) $(BOLD)Building release binary...$(RESET)\n" + @cargo build --release --bin $(APP_NAME) && \ + printf "$(GREEN)$(CHECK) Release build successful$(RESET)\n\n" || \ + (printf "$(RED)$(CROSS) Release build failed$(RESET)\n\n" && exit 1) + +run: + @printf "$(ARROW) Running $(APP_NAME) on port $(PORT)...\n" + @PORT=$(PORT) cargo run --bin $(APP_NAME) + +run-release: + @printf "$(ARROW) Running release build on port $(PORT)...\n" + @PORT=$(PORT) cargo run --release --bin $(APP_NAME) + +# ============== Test Targets ============== + +test: test-rust test-frontend test-e2e + @printf "\n$(GREEN)$(BOLD)$(CHECK) Standard validation passed$(RESET)\n\n" + +test-rust: banner + @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n" + @printf "$(CYAN)$(BOLD)║ Rust Test Suite ║$(RESET)\n" + @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n" + @printf "$(ARROW) $(BOLD)Running cargo test --quiet...$(RESET)\n" + @cargo test --quiet && \ + printf "\n$(GREEN)$(CHECK) Rust tests passed$(RESET)\n\n" || \ + (printf "\n$(RED)$(CROSS) Rust tests failed$(RESET)\n\n" && exit 1) + +test-frontend-syntax: require-node + @printf "$(PROGRESS) Checking frontend module syntax...\n" + @find static/app -name '*.mjs' -print0 | xargs -0 -n1 node --check && \ + printf "$(GREEN)$(CHECK) Frontend syntax checks passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Frontend syntax checks failed$(RESET)\n" && exit 1) + +test-frontend: test-frontend-syntax + @printf "$(PROGRESS) Running frontend tests...\n" + @node --test tests/frontend/*.test.js && \ + printf "$(GREEN)$(CHECK) Frontend tests passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Frontend tests failed$(RESET)\n" && exit 1) + +test-e2e: build-release require-node + @printf "$(PROGRESS) Running Playwright browser tests...\n" + @$(PLAYWRIGHT) test --config tests/e2e/playwright.config.js && \ + printf "$(GREEN)$(CHECK) Playwright browser tests passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Playwright browser tests failed$(RESET)\n" && exit 1) + +test-slow: banner + @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n" + @printf "$(CYAN)$(BOLD)║ Slow Acceptance Solve ║$(RESET)\n" + @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n" + @printf "$(ARROW) $(BOLD)Running large demo acceptance solve...$(RESET)\n" + @cargo test large_demo_solves_to_feasible_terminal_state -- --ignored --nocapture && \ + printf "\n$(GREEN)$(CHECK) Slow acceptance solve passed$(RESET)\n\n" || \ + (printf "\n$(RED)$(CROSS) Slow acceptance solve failed$(RESET)\n\n" && exit 1) + +test-one: + @printf "$(PROGRESS) Running test: $(YELLOW)$(TEST)$(RESET)\n" + @RUST_LOG=info cargo test $(TEST) -- --nocapture + +# ============== Lint & Format ============== + +fmt: + @printf "$(PROGRESS) Formatting code...\n" + @find src tests -name '*.rs' -print0 | xargs -0 rustfmt --edition 2021 + @printf "$(GREEN)$(CHECK) Code formatted$(RESET)\n" + +fmt-check: + @printf "$(PROGRESS) Checking formatting...\n" + @find src tests -name '*.rs' -print0 | xargs -0 rustfmt --edition 2021 --check && \ + printf "$(GREEN)$(CHECK) Formatting valid$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Formatting issues found$(RESET)\n" && exit 1) + +clippy: + @printf "$(PROGRESS) Running clippy...\n" + @cargo clippy --all-targets -- -D warnings && \ + printf "$(GREEN)$(CHECK) Clippy passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Clippy warnings found$(RESET)\n" && exit 1) + +lint: fmt-check clippy test-frontend-syntax + @printf "\n$(GREEN)$(BOLD)$(CHECK) Lint checks passed$(RESET)\n\n" + +check: lint test + +# ============== Space & Docker ============== + +docker-build: require-docker + @printf "$(PROGRESS) Building Docker image $(DOCKER_IMAGE)...\n" + @docker build -f "$(DOCKERFILE_PATH)" -t "$(DOCKER_IMAGE)" "$(DOCKER_CONTEXT)" && \ + printf "$(GREEN)$(CHECK) Docker image built$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Docker build failed$(RESET)\n" && exit 1) + +docker-run: require-docker + @printf "$(ARROW) Running $(DOCKER_IMAGE) on port $(PORT)...\n" + @docker run --rm -it -e PORT=$(PORT) -p $(PORT):$(PORT) "$(DOCKER_IMAGE)" + +space-build: docker-build + +space-run: space-build + @printf "$(GREEN)$(CHECK) Starting local container that mirrors the Space image$(RESET)\n" + @$(MAKE) docker-run --no-print-directory PORT=$(PORT) DOCKER_IMAGE=$(DOCKER_IMAGE) + +space-ci: ci-local + +# ============== CI & Release Validation ============== + +ci-local: banner + @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════════════════════════╗$(RESET)\n" + @printf "$(CYAN)$(BOLD)║ Local Space Validation Pipeline ║$(RESET)\n" + @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════════════════════════╝$(RESET)\n\n" + @printf "$(ARROW) $(BOLD)Simulating the checks we want green before a Space update...$(RESET)\n\n" + @printf "$(PROGRESS) Step 1/5: Format check...\n" + @$(MAKE) fmt-check --no-print-directory + @printf "$(PROGRESS) Step 2/5: Clippy...\n" + @$(MAKE) clippy --no-print-directory + @printf "$(PROGRESS) Step 3/5: Release build...\n" + @$(MAKE) build-release --no-print-directory + @printf "$(PROGRESS) Step 4/5: Standard test surface...\n" + @$(MAKE) test --no-print-directory + @printf "$(PROGRESS) Step 5/5: Docker/Space image build...\n" + @$(MAKE) space-build --no-print-directory + @printf "\n$(GREEN)$(BOLD)╔══════════════════════════════════════════════════════════╗$(RESET)\n" + @printf "$(GREEN)$(BOLD)║ $(CHECK) SPACE VALIDATION PASSED ║$(RESET)\n" + @printf "$(GREEN)$(BOLD)╚══════════════════════════════════════════════════════════╝$(RESET)\n\n" + +pre-release: banner + @printf "$(CYAN)$(BOLD)╔══════════════════════════════════════════════════════════╗$(RESET)\n" + @printf "$(CYAN)$(BOLD)║ Pre-Release Validation v$(VERSION) ║$(RESET)\n" + @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════════════════════════╝$(RESET)\n\n" + @$(MAKE) ci-local --no-print-directory + @printf "$(PROGRESS) Final step: slow acceptance solve...\n" + @$(MAKE) test-slow --no-print-directory + @printf "$(GREEN)$(BOLD)$(CHECK) Ready for a Space update$(RESET)\n\n" + +release-ci: ci-local + @printf "$(GREEN)$(BOLD)$(CHECK) Release CI passed for $(RELEASE_TAG)$(RESET)\n\n" + +release-info: + @printf "$(CYAN)Package:$(RESET) $(YELLOW)$(BOLD)$(PACKAGE_NAME)$(RESET)\n" + @printf "$(CYAN)Version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n" + @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n" + +# ============== Metadata & Cleanup ============== + +version: + @printf "$(CYAN)Current version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n" + @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n" + @printf "$(CYAN)Default port:$(RESET) $(YELLOW)$(BOLD)$(PORT)$(RESET)\n" + +clean: + @printf "$(ARROW) Cleaning build artifacts...\n" + @cargo clean + @printf "$(GREEN)$(CHECK) Clean complete$(RESET)\n" + +watch: + @printf "$(ARROW) Watching and rerunning the app on port $(PORT)...\n" + @cargo watch --version >/dev/null 2>&1 || \ + (printf "$(RED)$(CROSS) cargo-watch is required for make watch$(RESET)\n" && exit 1) + @cargo watch -x "run --bin $(APP_NAME)" + +# ============== Help ============== + +help: banner + @/bin/echo -e "$(CYAN)$(BOLD)Environment:$(RESET)" + @/bin/echo -e " $(GREEN)make doctor$(RESET) - Check toolchain and Docker readiness" + @/bin/echo -e "" + @/bin/echo -e "$(CYAN)$(BOLD)Build & Run:$(RESET)" + @/bin/echo -e " $(GREEN)make build$(RESET) - Build the app in debug mode" + @/bin/echo -e " $(GREEN)make build-release$(RESET) - Build the app in release mode" + @/bin/echo -e " $(GREEN)make run$(RESET) - Run the app locally on port $(PORT)" + @/bin/echo -e " $(GREEN)make run-release$(RESET) - Run the release build locally on port $(PORT)" + @/bin/echo -e "" + @/bin/echo -e "$(CYAN)$(BOLD)Tests & Validation:$(RESET)" + @/bin/echo -e " $(GREEN)make test$(RESET) - Run the standard Rust, frontend, and Playwright test surface" + @/bin/echo -e " $(GREEN)make test-rust$(RESET) - Run Rust tests only" + @/bin/echo -e " $(GREEN)make test-frontend$(RESET) - Run frontend syntax checks and tests" + @/bin/echo -e " $(GREEN)make test-e2e$(RESET) - Run Playwright browser tests" + @/bin/echo -e " $(GREEN)make test-slow$(RESET) - Run the ignored large-demo acceptance solve" + @/bin/echo -e " $(GREEN)make test-one TEST=name$(RESET) - Run a specific Rust test with output" + @/bin/echo -e " $(GREEN)make lint$(RESET) - Run fmt-check, clippy, and frontend syntax checks" + @/bin/echo -e " $(GREEN)make check$(RESET) - Run lint plus the standard test surface" + @/bin/echo -e " $(GREEN)make release-ci$(RESET) - Run the tag-publish CI gate for this app" + @/bin/echo -e "" + @/bin/echo -e "$(CYAN)$(BOLD)Space & Docker:$(RESET)" + @/bin/echo -e " $(GREEN)make space-build$(RESET) - Build the Docker image used for Space-style deployment" + @/bin/echo -e " $(GREEN)make space-run$(RESET) - Build and run that image locally on port $(PORT)" + @/bin/echo -e " $(GREEN)make ci-local$(RESET) - Simulate the pre-push validation for a Hugging Face Space" + @/bin/echo -e " $(GREEN)make pre-release$(RESET) - Run ci-local plus the slow acceptance solve" + @/bin/echo -e "" + @/bin/echo -e "$(CYAN)$(BOLD)Other:$(RESET)" + @/bin/echo -e " $(GREEN)make fmt$(RESET) - Format Rust code" + @/bin/echo -e " $(GREEN)make release-info$(RESET) - Show package version and app-scoped release tag" + @/bin/echo -e " $(GREEN)make version$(RESET) - Show version and default port" + @/bin/echo -e " $(GREEN)make clean$(RESET) - Clean build artifacts" + @/bin/echo -e " $(GREEN)make watch$(RESET) - Watch source files and rerun the app" + @/bin/echo -e " $(GREEN)make help$(RESET) - Show this help message" + @/bin/echo -e "" + @/bin/echo -e "$(GRAY)Rust version required: $(RUST_VERSION)$(RESET)" + @/bin/echo -e "$(GRAY)Current version: v$(VERSION)$(RESET)" + @/bin/echo -e "$(GRAY)Release tag: $(RELEASE_TAG)$(RESET)" + @/bin/echo -e "$(GRAY)Default port: $(PORT)$(RESET)" + @/bin/echo -e "" diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f12bbe45838a76bc065ed29a4055d75620cc94ac --- /dev/null +++ b/README.md @@ -0,0 +1,212 @@ +--- +title: SolverForge Hospital +emoji: 🏥 +colorFrom: red +colorTo: pink +sdk: docker +app_port: 7860 +pinned: false +license: apache-2.0 +short_description: SolverForge hospital scheduling example +--- + +# SolverForge Hospital + +![SolverForge Hospital screenshot](docs/screenshot.png) + +`solverforge-hospital` is a SolverForge employee-scheduling app with retained +jobs, schedule analysis, and a browser timeline workspace. + +It answers one concrete question: + +"Given a hospital workforce and a month of shifts, which employee should cover +each shift?" + +## Quick Start + +```sh +make run-release +``` + +Then open `http://localhost:7860`. + +To inspect the supported command surface: + +```sh +make help +``` + +## Documentation Map + +- `README.md` + Quick start, model concepts, validation, REST API, and solver policy. +- `WIREFRAME.md` + As-built architecture and runtime/data flow across backend, runtime, and UI. +- `docs/api-and-solver-policy.md` + Detailed route, payload, lifecycle, telemetry, and solver-policy reference. +- `AGENTS.md` + Codex-facing maintenance, validation, and documentation rules. +- `Makefile` + Supported local commands for development, validation, Docker, and Space work. +- `Dockerfile` + Docker Space image build using Rust 1.95 and the declared crates.io line. + +## Current Dependency Shape + +- Package: `solverforge-hospital`; version is declared in `Cargo.toml` +- Release binary: `solverforge-hospital` +- Rust: `1.95` +- SolverForge runtime: `solverforge` `0.19.3` +- Browser UI assets: `solverforge-ui` `0.6.5` +- Scaffold metadata: `solverforge-cli` `2.2.2` in `solverforge.app.toml` + +The app serves registry-backed Rust dependencies, local static browser modules, +and Axum API routes from one process. + +## Model Concepts + +- `Employee` is a problem fact: input staff data the solver reads but does not + move. +- `Shift` is the planning entity: each shift needs exactly one employee. +- `Shift.employee_idx` is the scalar planning variable: the employee index + SolverForge changes during construction and local search. +- `CareHub` is a derived domain grouping that makes nearby search prefer + employees close to the service line. +- `Plan` is the planning solution with the current `HardSoftDecimalScore`. + +The app ships one deterministic `LARGE` dataset with a 28-day horizon, 50 +employees, and 688 shifts. + +## Constraints + +Hard constraints: + +- Every shift is assigned. +- The assigned employee has the required skill. +- An employee is not assigned to overlapping shifts. +- An employee has at least 10 hours between two shifts. +- An employee works at most one shift per day. +- Unavailable employees are not assigned. + +Soft constraints: + +- Undesired days are avoided. +- Desired days are rewarded. +- Assignments are balanced across employees. + +## REST API + +- `GET /health` +- `GET /info` +- `GET /demo-data` +- `GET /demo-data/{id}` +- `POST /jobs` +- `GET /jobs/{id}` +- `DELETE /jobs/{id}` +- `GET /jobs/{id}/status` +- `GET /jobs/{id}/snapshot` +- `GET /jobs/{id}/analysis` +- `POST /jobs/{id}/pause` +- `POST /jobs/{id}/resume` +- `POST /jobs/{id}/cancel` +- `GET /jobs/{id}/events` + +`snapshot_revision={n}` is optional for snapshots and analysis. SSE clients +receive a bootstrap event and then live retained-job events. The browser also +exposes a visible REST API guide expected to match +`docs/api-and-solver-policy.md`. + +## Solver Policy + +`solver.toml` is embedded by `Plan` and is the runtime source of truth. + +- `cheapest_insertion` assigns employee indexes during construction. +- Local search uses nearby change and nearby swap moves over + `Shift.employee_idx`. +- Nearby search reads the app's care-hub distance signals so moves stay focused + on plausible employee/shift pairs. +- `late_acceptance` with an accepted-count forager keeps several candidate + moves alive per step. +- Solving stops after 30 seconds total or after 5 seconds without improvement. + +The hidden witness roster in `src/data/data_seed/witness.rs` shapes a +hard-feasible public instance, but the solver never receives the witness itself. + +## Validation + +Standard validation: + +```sh +make test +``` + +Full local validation: + +```sh +make ci-local +``` + +Slow acceptance solve: + +```sh +make test-slow +``` + +`make test` runs Rust tests, browserless frontend tests, and Playwright browser +tests. `make ci-local` adds formatting, clippy, release build, and Docker image +build. `make pre-release` runs `ci-local` plus the slow acceptance solve. + +## Hugging Face Space Deployment + +This repo is Docker-Space ready. The Space reads the README front matter, +builds `Dockerfile`, and expects the app to bind `PORT=7860`. + +Local Space-equivalent commands: + +```sh +make space-build +make space-run +``` + +## Read The Code In This Order + +1. `src/domain/employee.rs` + The staff problem fact model. +2. `src/domain/care_hub.rs` + Service-line grouping for nearby search. +3. `src/domain/mod.rs` + The `planning_model!` manifest and public domain exports. +4. `src/domain/plan.rs` + The `Shift` planning entity, `Plan` solution, derived fields, and nearby + meters. +5. `src/constraints/mod.rs` and `src/constraints/*.rs` + The score model, one scheduling rule per file. +6. `src/data/data_seed/entrypoints.rs` + Public demo-data IDs. +7. `src/data/data_seed/large.rs` and `src/data/data_seed/witness.rs` + The published instance builder and hidden feasibility witness. +8. `src/solver/service.rs` + Retained-job orchestration over `SolverManager`. +9. `src/api/routes.rs`, `src/api/dto.rs`, and `src/api/sse.rs` + HTTP routes, transport DTOs, and live-event streaming. +10. `static/app/main.mjs`, `static/app/shell/`, and `static/app/schedule/` + Browser boot sequence, shell, and timeline views. + +## Project Shape + +- `src/domain/` + Planning model, domain types, derived fields, and nearby meters. +- `src/constraints/` + Incremental SolverForge scoring rules. +- `src/data/` + Deterministic hospital demo-data generator. +- `src/solver/` + Retained-job facade and runtime event payload formatting. +- `src/api/` + Axum routes, DTOs, and SSE endpoint. +- `static/app/` + Browser modules built on stock `solverforge-ui` assets. +- `tests/frontend/` + Browserless UI tests using the fake DOM in `tests/support/`. +- `tests/e2e/` + Playwright browser tests for the served app. diff --git a/WIREFRAME.md b/WIREFRAME.md new file mode 100644 index 0000000000000000000000000000000000000000..5d463c292ba043cfb926aed07f9baf9c608cfff9 --- /dev/null +++ b/WIREFRAME.md @@ -0,0 +1,300 @@ +# solverforge-hospital WIREFRAME + +This file is the architectural map for beginners. + +If `README.md` tells you how to run the app, this document tells you how the +pieces fit together and in which order to read them. + +## Documentation Roles + +The docs in this repo are meant to work together rather than compete: + +- `README.md` + Quick start, concepts, and user-facing orientation. +- `WIREFRAME.md` + Architecture, execution flow, and file-map walkthrough. +- `docs/api-and-solver-policy.md` + REST routes, lifecycle semantics, payload shape, and solver policy notes. +- `AGENTS.md` + Rules for keeping code, comments, tests, and docs aligned in future changes. +- `Makefile` + The shared developer command surface, including the local Space validation + pipeline. +- `docs/screenshot.png` + Current browser screenshot embedded by the README. + +## What This Repo Is Teaching + +`solverforge-hospital` is a complete SolverForge example, not just a scoring +snippet. + +It shows how to build a planning app where: + +- the domain model is small and explicit +- the score rules are readable one file at a time +- the dataset is deterministic and intentionally shaped +- the solver runs as a retained background job +- the browser UI watches the solve through REST and SSE + +The planning question is: + +"For each hospital shift, which employee should be assigned?" + +## SolverForge Concepts In Plain Language + +- `Employee` + Input data. The solver reads it, but does not move it. +- `Shift` + The thing the solver is allowed to assign. +- `employee_idx` + The one real decision variable in this app. It points from a shift to one + employee inside `Plan.employees`. +- hard score + Rules that must not be broken, such as missing skills or overlapping shifts. +- soft score + Preferences and quality goals, such as honoring desired days or balancing + workload. +- retained job + A solve that keeps living in memory after it starts, so the UI can poll it, + pause it, resume it, stop it through runtime cancel, or inspect snapshots. + Delete is terminal cleanup before the next fresh Solve, not the Stop action. + +## Read Order + +If you are new to this repo, read files in this order: + +1. `src/domain/employee.rs` + Learn the input facts first. +2. `src/domain/care_hub.rs` + Learn the hospital service-line grouping used by nearby search. +3. `src/domain/mod.rs` + See the `planning_model!` manifest that lists and exports the model modules. +4. `src/domain/plan.rs` + Learn the planning entity, planning variable, nearby meters, and derived + fields. +5. `src/constraints/mod.rs` + See the full score model at a glance. +6. `src/constraints/*.rs` + Read one scheduling rule per file. +7. `src/data/data_seed/entrypoints.rs` + See the public demo-data surface. +8. `src/data/data_seed/large.rs` + See how the published dataset is assembled. +9. `src/solver/service.rs` + See how a domain solve becomes a retained runtime job. +10. `src/api/routes.rs` and `src/api/sse.rs` + See the HTTP contract. +11. `static/app/main.mjs` + See the browser boot sequence. +12. `static/app/shell/` and `static/app/schedule/` + See how stock `solverforge-ui` components are adapted to this hospital demo. + +## Runtime Flow + +The shortest way to understand the app is to follow one request all the way +through: + +1. The browser loads `static/index.html`. +2. `static/app/main.mjs` loads config and the generated UI model, validates the + configured `LARGE` id through `/demo-data`, and then fetches + `/demo-data/LARGE`. +3. The frontend turns the returned `PlanDto` into schedule rails and side-panel + summaries. +4. When the user clicks Solve, the frontend sends the current plan to + `POST /jobs`. +5. `src/api/routes.rs` converts that HTTP request into a `PlanDto`. +6. `PlanDto::to_domain()` rebuilds the in-memory `Plan`, including derived + helper fields the solver expects. +7. `SolverService` starts a retained solve through `SolverManager`. +8. The solver emits lifecycle events carrying telemetry and best-solution + snapshots. +9. `src/solver/service.rs` coordinates the event stream, while + `src/solver/service/payload.rs` converts runtime events into the JSON shapes + expected by the UI. +10. The browser consumes those events over `/jobs/{id}/events` and updates the + visible status and timeline; analysis is fetched from + `/jobs/{id}/analysis` when requested. + +The browser shell also contains a visible REST API guide. That makes +`static/app/shell/api-guide.mjs` part of the documentation surface, not just a +UI helper. + +## File Map + +```text +. +├── Cargo.toml +│ Rust crate metadata and registry dependency requests. +├── solver.toml +│ Embedded solver policy. This is the runtime source of truth for search. +├── solverforge.app.toml +│ App metadata, model surface, and the `solverforge 0.19.3` runtime target. +├── Dockerfile +│ Container build for running the app outside the dev checkout. +├── Makefile +│ Local build, validation, and Docker/Space workflow wrapper. +├── README.md +│ Beginner run guide and learning path. +├── docs/api-and-solver-policy.md +│ REST, payload, lifecycle, telemetry, and solver-policy reference. +├── WIREFRAME.md +│ This architectural walkthrough. +├── AGENTS.md +│ Repo-specific contributor and documentation rules. +├── docs/screenshot.png +│ Current browser screenshot used by the README. +├── src/ +│ ├── lib.rs +│ │ Crate root and public module surface. +│ ├── main.rs +│ │ Axum server bootstrap, CORS, static serving, and route composition. +│ ├── domain/ +│ │ `planning_model!` manifest plus problem model modules. +│ ├── constraints/ +│ │ One scheduling rule per file plus the assembler in `mod.rs`. +│ ├── data/ +│ │ Deterministic demo-data generator and published entrypoints. +│ ├── solver/ +│ │ Retained-job facade over the SolverForge runtime. +│ └── api/ +│ DTOs, REST routes, and SSE streaming. +├── static/ +│ ├── index.html +│ │ Browser entrypoint. +│ ├── sf-config.json +│ │ Runtime UI config for the stock frontend shell. +│ ├── generated/ui-model.json +│ │ Generated view metadata used by `solverforge-ui`. +│ └── app/ +│ ├── main.mjs +│ │ Browser boot and wiring. +│ ├── shell/ +│ │ App shell, state, solver controls, and panels. +│ ├── schedule/ +│ │ Hospital-specific grouping, presentation, and rail rendering. +│ └── views/registry.mjs +│ Named view registration. +└── tests/ + ├── frontend/ + │ Browserless frontend tests. + ├── e2e/ + │ Playwright browser tests for the served app. + └── support/ + Fake DOM support used by the frontend tests. +``` + +## Why The Model Looks This Way + +This app is intentionally narrow. + +- There is one planning entity type: `Shift`. +- There is one scalar planning variable: `employee_idx`. +- Nearby search is attached directly to that scalar variable. +- The solver does not juggle multiple variable types, sequence assignments, or + list planning. + +That makes the example easier to learn because the optimization problem stays +visible: + +- facts live in `Plan.employees` +- decisions live in `Plan.shifts[*].employee_idx` +- score rules read those two things and judge the assignment + +## Why The Demo Data Is Structured + +The demo-data generator is not filler. + +It is designed to give beginners a problem that is: + +- deterministic +- feasible +- interesting enough that local search still has work to do +- stable enough that tests and comparisons stay meaningful + +One important design trick is the hidden witness roster in +`src/data/data_seed/witness.rs`. + +That internal roster gives the generator a known feasible staffing pattern. +The published dataset is then shaped around that witness, while the solver only +sees the final public problem. This lets the repo ship a realistic-feeling +demo without random feasibility failures. + +## Why The Runtime Uses REST And SSE + +The solve is long-lived compared with a normal request/response handler, so the +backend splits the contract into two parts: + +- REST for control and snapshots +- SSE for live progress + +The REST surface is: + +- `/health` and `/info` expose liveness and app metadata. +- `/demo-data` and `/demo-data/{id}` expose the deterministic demo catalog. +- `/jobs` creates a retained solver job. +- `/jobs/{id}` and `/jobs/{id}/status` expose summary state. +- `/jobs/{id}/snapshot` returns an exact or latest snapshot. +- `/jobs/{id}/analysis` runs constraint analysis for a snapshot. +- `/jobs/{id}/pause`, `/jobs/{id}/resume`, and `/jobs/{id}/cancel` control a + live job. +- `DELETE /jobs/{id}` removes a terminal retained job. +- `/jobs/{id}/events` streams typed lifecycle events. + +That separation keeps the frontend simple: + +- create a job +- poll or fetch details when needed +- subscribe once to the live event stream + +It also mirrors how a real retained SolverForge app behaves in production. + +One small but important detail: `GET /jobs/{id}` and `GET /jobs/{id}/status` +return the same summary payload. The second route exists as a stock-compatible +alias for clients that expect the explicit `/status` URL shape. + +## Solver Policy + +`solver.toml` is embedded by `Plan` and is therefore part of the actual model, +not a side document. + +The shipped search policy is deliberately conservative: + +- `cheapest_insertion` builds a feasible first assignment +- local search stays in the nearby scalar neighborhood +- `late_acceptance` and `accepted_count` keep the search moving without blowing + up step cost + +That narrow configuration is the shipped 30-second policy for this demo. +`make test-slow` is the acceptance gate for any solver-policy change. + +## Frontend Design + +The frontend is intentionally thin. + +This repo is not trying to teach a custom framework. It is showing how to take +stock `solverforge-ui` pieces and adapt them to one concrete planning problem. + +- `shell/` owns app lifecycle, backend wiring, and side panels +- `schedule/` owns the hospital-specific transformation from domain data to + visual rails +- tests in `tests/frontend/` lock browserless presentation behavior down +- tests in `tests/e2e/` verify the served browser app with Playwright + +## Validation Surfaces + +When you change this repo, think in six separate layers: + +1. Domain and constraint logic: + `make test-rust` +2. Slow end-to-end solve quality: + `make test-slow` +3. Frontend module correctness: + `make test-frontend-syntax` +4. Frontend module behavior: + `make test-frontend` +5. Served browser behavior: + `make test-e2e` +6. Local deploy readiness for the Docker-based Space target: + `make ci-local` + +If you keep those six layers green, the repo remains teachable and usable. diff --git a/docs/api-and-solver-policy.md b/docs/api-and-solver-policy.md new file mode 100644 index 0000000000000000000000000000000000000000..ac4205b069163a0aca04c0f129acc70dd857e705 --- /dev/null +++ b/docs/api-and-solver-policy.md @@ -0,0 +1,90 @@ +# API And Solver Policy + +This page holds the longer reference material that must stay aligned with +`src/api/routes.rs`, `src/api/dto.rs`, `src/solver/service.rs`, +`src/solver/service/payload.rs`, `solver.toml`, and the visible API guide in +`static/app/shell/api-guide.mjs`. + +## REST API + +- `GET /health` +- `GET /info` +- `GET /demo-data` +- `GET /demo-data/{id}` +- `POST /jobs` +- `GET /jobs/{id}` +- `GET /jobs/{id}/status` +- `GET /jobs/{id}/snapshot` +- `GET /jobs/{id}/analysis` +- `POST /jobs/{id}/pause` +- `POST /jobs/{id}/resume` +- `POST /jobs/{id}/cancel` +- `DELETE /jobs/{id}` +- `GET /jobs/{id}/events` + +## Lifecycle Semantics + +- `pause` requests an exact runtime-managed pause and checkpoint +- `resume` continues from the retained checkpoint +- the user-facing Stop control calls `cancel` to stop a live or paused job +- `delete` removes a terminal retained job before the next fresh solve +- `GET /jobs/{id}` and `GET /jobs/{id}/status` return the same summary payload +- `snapshot_revision={n}` is optional on both snapshot and analysis requests +- reconnects bootstrap from current runtime status plus retained snapshot + revision, not from cached SSE text + +## Payload Shape + +The transport payload mirrors the domain model directly. The important field is +`employeeIdx`, which is the scalar planning assignment chosen for each shift. + +```json +{ + "employees": [ + { + "id": "employee-0", + "name": "Alex Smith", + "homeHub": "critical_care", + "skills": ["Critical care doctor"], + "unavailableDates": [], + "undesiredDates": [], + "desiredDates": [] + } + ], + "shifts": [ + { + "id": "shift-1", + "start": "2024-01-01T08:00:00", + "end": "2024-01-01T16:00:00", + "location": "Critical care", + "careHub": "critical_care", + "requiredSkill": "Critical care doctor", + "employeeIdx": 0 + } + ], + "score": "0hard/0soft" +} +``` + +Telemetry is intentionally a UI-facing projection, not the raw runtime type. +Fields like `elapsedMs`, `movesPerSecond`, and `acceptanceRate` are derived for +display. + +## Solver Policy + +The runtime source of truth is [../solver.toml](../solver.toml). + +The currently shipped policy is deliberately narrow: + +- `construction_heuristic = cheapest_insertion` +- one local-search phase +- nearby scalar change/swap selectors +- `late_acceptance` +- `accepted_count` + +This is the policy exercised by the standard runtime tests. `make test-slow` +runs the ignored large-demo acceptance solve and is the required quality gate +when changing that policy. + +The canonical description of current behavior is `solver.toml`, the code, +`README.md`, this file, and `WIREFRAME.md`. diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..b0b40a5a8ded59e7fd2c3aae3c44880ed80400a4 --- /dev/null +++ b/docs/screenshot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f7678f6eccd57c55e8f5d550c5ac3614fd3f8325177eeafec620d9273014e4e +size 150982 diff --git a/solver.toml b/solver.toml new file mode 100644 index 0000000000000000000000000000000000000000..a08c64df7a00420c6112d329774ac78a3ef0ac78 --- /dev/null +++ b/solver.toml @@ -0,0 +1,43 @@ +# SolverForge configuration for the hospital example. +# +# This app uses scalar nearby selection directly. The care-hub meters in +# `src/domain/plan.rs` keep local search focused enough to make real progress +# on the hospital dataset inside the configured time limit. + +random_seed = 1 + +[termination] +seconds_spent_limit = 30 +unimproved_seconds_spent_limit = 5 + +[[phases]] +type = "construction_heuristic" +construction_heuristic_type = "cheapest_insertion" +entity_class = "Shift" +variable_name = "employee_idx" + +[[phases]] +type = "local_search" + +[phases.acceptor] +type = "late_acceptance" +late_acceptance_size = 400 + +[phases.forager] +type = "accepted_count" +limit = 4 + +[phases.move_selector] +type = "union_move_selector" + +[[phases.move_selector.selectors]] +type = "nearby_change_move_selector" +entity_class = "Shift" +variable_name = "employee_idx" +max_nearby = 10 + +[[phases.move_selector.selectors]] +type = "nearby_swap_move_selector" +entity_class = "Shift" +variable_name = "employee_idx" +max_nearby = 10 diff --git a/solverforge.app.toml b/solverforge.app.toml new file mode 100644 index 0000000000000000000000000000000000000000..ae192fcb20eb792d247c7dd9b4687f3e28d4ee12 --- /dev/null +++ b/solverforge.app.toml @@ -0,0 +1,83 @@ +[app] +name = "SolverForge Hospital" +starter = "neutral-shell" +shell = "web" +cli_version = "2.2.2" + +[runtime] +target = "solverforge 0.19.3" +runtime_source = "crates.io: solverforge 0.19.3" +ui_source = "crates.io: solverforge-ui 0.6.5" + +[demo] +default_size = "LARGE" +available_sizes = ["LARGE"] + +[solution] +name = "Plan" +score = "HardSoftDecimalScore" + +[[facts]] +name = "employee" +plural = "employees" +kind = "problem_fact" + +[[entities]] +name = "shift" +plural = "shifts" +kind = "planning_entity" + +[[variables]] +entity = "shift" +entity_plural = "shifts" +field = "employee_idx" +kind = "scalar" +range = "employees" +elements = "" +allows_unassigned = true +enabled = true + +[[constraints]] +name = "assigned_shift" +module = "assigned_shift" +enabled = true + +[[constraints]] +name = "required_skill" +module = "required_skill" +enabled = true + +[[constraints]] +name = "overlapping_shift" +module = "overlapping_shift" +enabled = true + +[[constraints]] +name = "minimum_rest" +module = "minimum_rest" +enabled = true + +[[constraints]] +name = "one_shift_per_day" +module = "one_shift_per_day" +enabled = true + +[[constraints]] +name = "unavailable_employee" +module = "unavailable_employee" +enabled = true + +[[constraints]] +name = "undesired_day" +module = "undesired_day" +enabled = true + +[[constraints]] +name = "desired_day" +module = "desired_day" +enabled = true + +[[constraints]] +name = "balance_assignments" +module = "balance_assignments" +enabled = true diff --git a/src/api/dto.rs b/src/api/dto.rs new file mode 100644 index 0000000000000000000000000000000000000000..ada4ecd20436e790c2086fb4953e1ff99c1a85b5 --- /dev/null +++ b/src/api/dto.rs @@ -0,0 +1,259 @@ +//! Transport DTOs that turn domain/runtime types into beginner-friendly JSON. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use solverforge::{ + HardSoftDecimalScore, ScoreAnalysis, SolverLifecycleState, SolverSnapshot, + SolverSnapshotAnalysis, SolverStatus, SolverTelemetry, SolverTerminalReason, +}; +use std::time::Duration; + +use crate::domain::Plan; + +/// Thin JSON wrapper around the planning solution. +/// +/// We keep the plan fields flattened so the API payload reads like the domain +/// model instead of like a transport envelope. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanDto { + #[serde(flatten)] + pub fields: Map, + #[serde(default)] + pub score: Option, +} + +/// One constraint row shown in the Analyze modal. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConstraintAnalysisDto { + pub name: String, + pub weight: String, + pub score: String, + pub match_count: usize, +} + +/// Top-level analysis payload returned by `/jobs/{id}/analysis`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalyzeResponse { + pub score: String, + pub constraints: Vec, +} + +/// UI-facing telemetry summary derived from exact runtime telemetry. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TelemetryDto { + pub elapsed_ms: u64, + pub step_count: u64, + pub moves_generated: u64, + pub moves_evaluated: u64, + pub moves_accepted: u64, + pub score_calculations: u64, + pub generation_ms: u64, + pub evaluation_ms: u64, + pub moves_per_second: u64, + pub acceptance_rate: f64, +} + +/// Compact job summary returned by `/jobs/{id}` and `/jobs/{id}/status`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobSummaryDto { + pub id: String, + pub job_id: String, + pub lifecycle_state: &'static str, + pub terminal_reason: Option<&'static str>, + pub checkpoint_available: bool, + pub event_sequence: u64, + pub snapshot_revision: Option, + pub current_score: Option, + pub best_score: Option, + pub telemetry: TelemetryDto, +} + +/// Snapshot payload returned by `/jobs/{id}/snapshot`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobSnapshotDto { + pub id: String, + pub job_id: String, + pub snapshot_revision: u64, + pub lifecycle_state: &'static str, + pub terminal_reason: Option<&'static str>, + pub current_score: Option, + pub best_score: Option, + pub telemetry: TelemetryDto, + pub solution: PlanDto, +} + +/// Analysis payload tied to a specific retained snapshot revision. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobAnalysisDto { + pub id: String, + pub job_id: String, + pub snapshot_revision: u64, + pub lifecycle_state: &'static str, + pub terminal_reason: Option<&'static str>, + pub analysis: AnalyzeResponse, +} + +impl PlanDto { + /// Captures the domain plan as a JSON object while keeping `score` explicit. + pub fn from_plan(plan: &Plan) -> Self { + let mut fields = plan.to_transport_fields(); + fields.remove("score"); + + Self { + fields, + score: plan.score.map(|score| score.to_string()), + } + } + + /// Rebuilds the normalized domain model from the flattened JSON payload. + pub fn to_domain(&self) -> Result { + Plan::from_transport_fields(self.fields.clone()) + } +} + +impl TelemetryDto { + /// Projects exact runtime telemetry into the fields the browser displays. + pub fn from_runtime(telemetry: &SolverTelemetry) -> Self { + Self { + elapsed_ms: duration_millis_u64(telemetry.elapsed), + step_count: telemetry.step_count, + moves_generated: telemetry.moves_generated, + moves_evaluated: telemetry.moves_evaluated, + moves_accepted: telemetry.moves_accepted, + score_calculations: telemetry.score_calculations, + generation_ms: duration_millis_u64(telemetry.generation_time), + evaluation_ms: duration_millis_u64(telemetry.evaluation_time), + moves_per_second: moves_per_second(telemetry.moves_evaluated, telemetry.elapsed), + acceptance_rate: acceptance_rate(telemetry.moves_accepted, telemetry.moves_evaluated), + } + } +} + +/// Small helper so the JSON surface stays integer-based. +fn duration_millis_u64(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +/// Derives whole moves per second from exact elapsed time. +fn moves_per_second(moves_evaluated: u64, elapsed: Duration) -> u64 { + let nanos = elapsed.as_nanos(); + if nanos == 0 { + return 0; + } + + let rate = u128::from(moves_evaluated) + .saturating_mul(1_000_000_000) + .checked_div(nanos) + .unwrap_or(0); + u64::try_from(rate).unwrap_or(u64::MAX) +} + +/// Derives acceptance as a decimal fraction for easy frontend display. +fn acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 { + if moves_evaluated == 0 { + 0.0 + } else { + moves_accepted as f64 / moves_evaluated as f64 + } +} + +impl JobSummaryDto { + /// Converts the runtime status summary into the JSON contract used by the app. + pub fn from_status(job_id: usize, status: &SolverStatus) -> Self { + Self { + id: job_id.to_string(), + job_id: job_id.to_string(), + lifecycle_state: lifecycle_state_label(status.lifecycle_state), + terminal_reason: status.terminal_reason.map(terminal_reason_label), + checkpoint_available: status.checkpoint_available, + event_sequence: status.event_sequence, + snapshot_revision: status.latest_snapshot_revision, + current_score: status.current_score.map(|score| score.to_string()), + best_score: status.best_score.map(|score| score.to_string()), + telemetry: TelemetryDto::from_runtime(&status.telemetry), + } + } +} + +impl JobSnapshotDto { + /// Converts a retained snapshot into the richer snapshot JSON payload. + pub fn from_snapshot(snapshot: &SolverSnapshot) -> Self { + Self { + id: snapshot.job_id.to_string(), + job_id: snapshot.job_id.to_string(), + snapshot_revision: snapshot.snapshot_revision, + lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state), + terminal_reason: snapshot.terminal_reason.map(terminal_reason_label), + current_score: snapshot.current_score.map(|score| score.to_string()), + best_score: snapshot.best_score.map(|score| score.to_string()), + telemetry: TelemetryDto::from_runtime(&snapshot.telemetry), + solution: PlanDto::from_plan(&snapshot.solution), + } + } +} + +impl JobAnalysisDto { + /// Packages exact snapshot analysis together with snapshot identity metadata. + pub fn from_snapshot_analysis( + snapshot: &SolverSnapshotAnalysis, + analysis: AnalyzeResponse, + ) -> Self { + Self { + id: snapshot.job_id.to_string(), + job_id: snapshot.job_id.to_string(), + snapshot_revision: snapshot.snapshot_revision, + lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state), + terminal_reason: snapshot.terminal_reason.map(terminal_reason_label), + analysis, + } + } +} + +/// Converts SolverForge's detailed score analysis into the browser response shape. +pub fn analysis_response(analysis: &ScoreAnalysis) -> AnalyzeResponse { + AnalyzeResponse { + score: analysis.score.to_string(), + constraints: analysis + .constraints + .iter() + .map(|constraint| ConstraintAnalysisDto { + name: constraint.name.clone(), + weight: constraint.weight.to_string(), + score: constraint.score.to_string(), + match_count: constraint.match_count, + }) + .collect(), + } +} + +/// Re-exports lifecycle labels so routes and tests share one mapping. +pub fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str { + match state { + SolverLifecycleState::Solving => "SOLVING", + SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED", + SolverLifecycleState::Paused => "PAUSED", + SolverLifecycleState::Completed => "COMPLETED", + SolverLifecycleState::Cancelled => "CANCELLED", + SolverLifecycleState::Failed => "FAILED", + } +} + +/// Re-exports terminal labels so routes and tests share one mapping. +pub fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str { + match reason { + SolverTerminalReason::Completed => "completed", + SolverTerminalReason::TerminatedByConfig => "terminated_by_config", + SolverTerminalReason::Cancelled => "cancelled", + SolverTerminalReason::Failed => "failed", + } +} + +#[cfg(test)] +mod tests; diff --git a/src/api/dto/tests.rs b/src/api/dto/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..5dcf2c692e40ea3367e475e0d12f65c0b31d5b2f --- /dev/null +++ b/src/api/dto/tests.rs @@ -0,0 +1,146 @@ +//! DTO tests that keep transport JSON and generated UI metadata aligned. + +use super::*; +use serde::Deserialize; +use solverforge::ConstraintSet; +use std::fs; +use std::time::Duration; + +#[derive(Deserialize)] +struct UiModel { + entities: Vec, + facts: Vec, + constraints: Vec, + views: Vec, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct UiConstraint { + name: String, + #[serde(rename = "type")] + constraint_type: String, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct UiNamedEntry { + name: String, + plural: String, + label: String, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct UiView { + id: String, + kind: String, + label: String, + entity: String, + entity_plural: String, + source_plural: String, + variable_field: String, + allows_unassigned: bool, +} + +#[test] +fn plan_dto_returns_decode_errors_for_semantically_invalid_payloads() { + let dto = PlanDto { + fields: Map::new(), + score: None, + }; + + assert!(dto.to_domain().is_err()); +} + +#[test] +fn runtime_telemetry_derives_stock_transport_fields() { + let telemetry = SolverTelemetry { + elapsed: Duration::from_millis(2_500), + step_count: 9, + moves_generated: 300, + moves_evaluated: 200, + moves_accepted: 50, + score_calculations: 80, + generation_time: Duration::from_millis(400), + evaluation_time: Duration::from_millis(900), + ..SolverTelemetry::default() + }; + + let dto = TelemetryDto::from_runtime(&telemetry); + + assert_eq!(dto.elapsed_ms, 2_500); + assert_eq!(dto.step_count, 9); + assert_eq!(dto.moves_generated, 300); + assert_eq!(dto.moves_evaluated, 200); + assert_eq!(dto.moves_accepted, 50); + assert_eq!(dto.score_calculations, 80); + assert_eq!(dto.generation_ms, 400); + assert_eq!(dto.evaluation_ms, 900); + assert_eq!(dto.moves_per_second, 80); + assert!((dto.acceptance_rate - 0.25).abs() < f64::EPSILON); +} + +#[test] +fn analyzed_constraint_names_match_ui_model() { + let ui_model_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/static/generated/ui-model.json" + ); + let ui_model: UiModel = + serde_json::from_str(&fs::read_to_string(ui_model_path).unwrap()).unwrap(); + + let plan = Plan::new(Vec::new(), Vec::new()); + let constraints = crate::constraints::create_constraints(); + let analysis = constraints.evaluate_detailed(&plan); + let analyzed_constraints: Vec = analysis + .iter() + .map(|analysis| analysis.constraint_ref.name.to_string()) + .collect(); + + let ui_constraints: Vec = ui_model + .constraints + .iter() + .map(|constraint| constraint.name.clone()) + .collect(); + assert_eq!(analyzed_constraints, ui_constraints); + assert_eq!( + ui_model.entities, + vec![UiNamedEntry { + name: "shift".to_string(), + plural: "shifts".to_string(), + label: "Shifts".to_string(), + }] + ); + assert_eq!( + ui_model.facts, + vec![UiNamedEntry { + name: "employee".to_string(), + plural: "employees".to_string(), + label: "Employees".to_string(), + }] + ); + assert_eq!( + ui_model.views, + vec![ + UiView { + id: "by-location".to_string(), + kind: "schedule-by-location".to_string(), + label: "By location".to_string(), + entity: "shift".to_string(), + entity_plural: "shifts".to_string(), + source_plural: "employees".to_string(), + variable_field: "employeeIdx".to_string(), + allows_unassigned: true, + }, + UiView { + id: "by-employee".to_string(), + kind: "schedule-by-employee".to_string(), + label: "By employee".to_string(), + entity: "shift".to_string(), + entity_plural: "shifts".to_string(), + source_plural: "employees".to_string(), + variable_field: "employeeIdx".to_string(), + allows_unassigned: true, + } + ] + ); +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..3a12dca138f90998db32b05f27bd79f085535de6 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,13 @@ +//! HTTP transport surface for the example application. +//! +//! The API layer is split into: +//! - DTOs that translate runtime/domain types into JSON +//! - routes that bind those DTOs to HTTP endpoints +//! - SSE glue for live lifecycle updates + +mod dto; +mod routes; +mod sse; + +pub use dto::PlanDto; +pub use routes::{router, AppState}; diff --git a/src/api/routes.rs b/src/api/routes.rs new file mode 100644 index 0000000000000000000000000000000000000000..95bc0bb65d919524ca7fd5df0febb3cc07618ecc --- /dev/null +++ b/src/api/routes.rs @@ -0,0 +1,225 @@ +//! HTTP routes for the hospital example. +//! +//! These handlers stay intentionally small: each one should read like +//! "decode request -> call `SolverService` -> encode response". + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use super::dto::{analysis_response, JobAnalysisDto, JobSnapshotDto, JobSummaryDto, PlanDto}; +use super::sse; +use crate::data::{self, DemoData}; +use crate::solver::SolverService; + +/// Shared application state stored inside Axum. +pub struct AppState { + pub solver: SolverService, +} + +impl AppState { + /// Builds the shared runtime facade once for the whole router. + pub fn new() -> Self { + Self { + solver: SolverService::new(), + } + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +/// Registers the full public HTTP surface of the example app. +pub fn router(state: Arc) -> Router { + Router::new() + .route("/health", get(health)) + .route("/info", get(info)) + .route("/demo-data", get(list_demo_data)) + .route("/demo-data/{id}", get(get_demo_data)) + .route("/jobs", post(create_job)) + .route("/jobs/{id}", get(get_job).delete(delete_job)) + .route("/jobs/{id}/status", get(get_job_status)) + .route("/jobs/{id}/snapshot", get(get_snapshot)) + .route("/jobs/{id}/analysis", get(analyze_by_id)) + .route("/jobs/{id}/pause", post(pause_job)) + .route("/jobs/{id}/resume", post(resume_job)) + .route("/jobs/{id}/cancel", post(cancel_job)) + .route("/jobs/{id}/events", get(sse::events)) + .with_state(state) +} + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, +} + +/// Liveness probe used by demos and container platforms. +async fn health() -> Json { + Json(HealthResponse { status: "UP" }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InfoResponse { + name: &'static str, + version: &'static str, + solver_engine: &'static str, +} + +/// Tiny self-description endpoint for the UI and quick manual checks. +async fn info() -> Json { + Json(InfoResponse { + name: env!("CARGO_PKG_NAME"), + version: env!("CARGO_PKG_VERSION"), + solver_engine: "SolverForge", + }) +} + +/// Lists the demo ids accepted by `/demo-data/{id}`. +async fn list_demo_data() -> Json> { + Json(data::list_demo_data()) +} + +/// Materializes one demo dataset and returns it as a `PlanDto`. +async fn get_demo_data(Path(id): Path) -> Result, StatusCode> { + let demo = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + Ok(Json(PlanDto::from_plan(&data::generate(demo)))) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateJobResponse { + id: String, +} + +/// Starts a retained solve for the posted plan payload. +async fn create_job( + State(state): State>, + Json(dto): Json, +) -> Result, StatusCode> { + let plan = dto.to_domain().map_err(|_| StatusCode::BAD_REQUEST)?; + let id = state + .solver + .start_job(plan) + .map_err(status_from_solver_error)?; + Ok(Json(CreateJobResponse { id })) +} + +/// Returns the current retained-job summary. +async fn get_job( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + let job_id = parse_job_id(&id)?; + let status = state + .solver + .get_status(&id) + .map_err(status_from_solver_error)?; + Ok(Json(JobSummaryDto::from_status(job_id, &status))) +} + +/// Alias route kept for the stock job-summary URL shape. +async fn get_job_status( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + get_job(State(state), Path(id)).await +} + +#[derive(Debug, Default, Deserialize)] +struct SnapshotQuery { + snapshot_revision: Option, +} + +/// Fetches either the latest retained snapshot or an exact revision. +async fn get_snapshot( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let snapshot = state + .solver + .get_snapshot(&id, query.snapshot_revision) + .map_err(status_from_solver_error)?; + Ok(Json(JobSnapshotDto::from_snapshot(&snapshot))) +} + +/// Runs exact score analysis against a retained snapshot revision. +async fn analyze_by_id( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let snapshot_analysis = state + .solver + .analyze_snapshot(&id, query.snapshot_revision) + .map_err(status_from_solver_error)?; + let analysis = analysis_response(&snapshot_analysis.analysis); + Ok(Json(JobAnalysisDto::from_snapshot_analysis( + &snapshot_analysis, + analysis, + ))) +} + +/// Requests that the runtime pause the job at the next exact safe point. +async fn pause_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.pause(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +/// Resumes a paused retained job. +async fn resume_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.resume(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +/// Cancels a live or paused retained job. +async fn cancel_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.cancel(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +/// Deletes a terminal retained job and its cached SSE state. +async fn delete_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.delete(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Parses the path segment into the numeric runtime job id. +fn parse_job_id(id: &str) -> Result { + id.parse::().map_err(|_| StatusCode::NOT_FOUND) +} + +/// Maps stock runtime errors onto the HTTP semantics the UI expects. +fn status_from_solver_error(error: solverforge::SolverManagerError) -> StatusCode { + match error { + solverforge::SolverManagerError::NoFreeJobSlots => StatusCode::SERVICE_UNAVAILABLE, + solverforge::SolverManagerError::JobNotFound { .. } => StatusCode::NOT_FOUND, + solverforge::SolverManagerError::InvalidStateTransition { .. } => StatusCode::CONFLICT, + solverforge::SolverManagerError::NoSnapshotAvailable { .. } => StatusCode::CONFLICT, + solverforge::SolverManagerError::SnapshotNotFound { .. } => StatusCode::NOT_FOUND, + } +} + +#[cfg(test)] +mod tests; diff --git a/src/api/routes/tests.rs b/src/api/routes/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..aae52ce2efd80dd35b7c14d55033856b81d7a9de --- /dev/null +++ b/src/api/routes/tests.rs @@ -0,0 +1,239 @@ +//! Route tests for the retained-job HTTP contract. + +use super::*; +use axum::body::{to_bytes, Body}; +use axum::http::Request; +use tower::util::ServiceExt; + +use crate::domain::{Employee, Plan}; + +fn empty_plan() -> PlanDto { + PlanDto::from_plan(&Plan::new( + vec![Employee::new(0, "Alex").with_skill("Doctor")], + vec![], + )) +} + +fn heavy_plan() -> PlanDto { + PlanDto::from_plan(&data::generate(DemoData::Large)) +} + +async fn json_body(response: axum::response::Response) -> serde_json::Value { + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&body).unwrap() +} + +async fn post_plan(app: &Router, path: &str, plan: &PlanDto) -> axum::response::Response { + post_json_bytes(app, path, serde_json::to_vec(plan).unwrap()).await +} + +async fn post_json_bytes(app: &Router, path: &str, body: Vec) -> axum::response::Response { + app.clone() + .oneshot( + Request::post(path) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap() +} + +async fn request(app: &Router, method: &str, path: &str) -> axum::response::Response { + app.clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() +} + +async fn wait_for_status( + app: &Router, + id: &str, + predicate: impl Fn(&serde_json::Value) -> bool, +) -> serde_json::Value { + for _ in 0..200 { + let response = request(app, "GET", &format!("/jobs/{id}")).await; + if response.status() == StatusCode::OK { + let json = json_body(response).await; + if predicate(&json) { + return json; + } + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + panic!("timed out waiting for job status"); +} + +async fn wait_for_ok_json(app: &Router, path: &str) -> serde_json::Value { + for _ in 0..200 { + let response = request(app, "GET", path).await; + if response.status() == StatusCode::OK { + return json_body(response).await; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + panic!("timed out waiting for successful response on {path}"); +} + +async fn wait_for_terminal(app: &Router, id: &str) -> serde_json::Value { + wait_for_status(app, id, |json| { + matches!( + json["lifecycleState"].as_str(), + Some("COMPLETED") | Some("CANCELLED") | Some("FAILED") + ) + }) + .await +} + +async fn cleanup_job(app: &Router, id: &str) { + let status = request(app, "GET", &format!("/jobs/{id}")).await; + if status.status() == StatusCode::NOT_FOUND { + return; + } + + let summary = json_body(status).await; + let lifecycle = summary["lifecycleState"].as_str().unwrap_or(""); + if !matches!(lifecycle, "COMPLETED" | "CANCELLED" | "FAILED") { + let cancel = request(app, "POST", &format!("/jobs/{id}/cancel")).await; + assert!( + cancel.status() == StatusCode::ACCEPTED || cancel.status() == StatusCode::CONFLICT, + "unexpected cancel status {}", + cancel.status() + ); + let _ = wait_for_terminal(app, id).await; + } + + let delete = request(app, "DELETE", &format!("/jobs/{id}")).await; + assert!( + delete.status() == StatusCode::NO_CONTENT || delete.status() == StatusCode::NOT_FOUND, + "unexpected delete status {}", + delete.status() + ); +} + +#[tokio::test] +async fn stock_jobs_contract_is_exposed_without_schedule_compatibility() { + let app = router(Arc::new(AppState::new())); + + let create = post_plan(&app, "/jobs", &empty_plan()).await; + assert_eq!(create.status(), StatusCode::OK); + let create_json = json_body(create).await; + let terminal_id = create_json["id"].as_str().unwrap().to_string(); + assert!(!terminal_id.is_empty()); + assert!(create_json.get("jobId").is_none()); + + let summary = wait_for_status(&app, &terminal_id, |_| true).await; + assert_eq!(summary["id"], terminal_id); + assert_eq!(summary["jobId"], terminal_id); + assert!(summary.get("lifecycleState").is_some()); + assert!(summary.get("checkpointAvailable").is_some()); + assert!(summary.get("eventSequence").is_some()); + assert!(summary.get("telemetry").is_some()); + + let snapshot = wait_for_ok_json(&app, &format!("/jobs/{terminal_id}/snapshot")).await; + assert_eq!(snapshot["id"], terminal_id); + assert_eq!(snapshot["jobId"], terminal_id); + assert!(snapshot.get("snapshotRevision").is_some()); + assert!(snapshot.get("solution").is_some()); + + let cancel_empty = request(&app, "POST", &format!("/jobs/{terminal_id}/cancel")).await; + assert_eq!(cancel_empty.status(), StatusCode::ACCEPTED); + let terminal = wait_for_terminal(&app, &terminal_id).await; + assert_eq!(terminal["lifecycleState"], "CANCELLED"); + + let analysis = wait_for_ok_json(&app, &format!("/jobs/{terminal_id}/analysis")).await; + assert_eq!(analysis["id"], terminal_id); + assert_eq!(analysis["jobId"], terminal_id); + assert!(analysis.get("analysis").is_some()); + + let cancel_again = request(&app, "POST", &format!("/jobs/{terminal_id}/cancel")).await; + assert_eq!(cancel_again.status(), StatusCode::CONFLICT); + + let delete_terminal = request(&app, "DELETE", &format!("/jobs/{terminal_id}")).await; + assert_eq!(delete_terminal.status(), StatusCode::NO_CONTENT); + + let missing_status = request(&app, "GET", &format!("/jobs/{terminal_id}/status")).await; + assert_eq!(missing_status.status(), StatusCode::NOT_FOUND); + + let live_create = post_plan(&app, "/jobs", &heavy_plan()).await; + assert_eq!(live_create.status(), StatusCode::OK); + let live_id = json_body(live_create).await["id"] + .as_str() + .unwrap() + .to_string(); + + let _ = wait_for_status(&app, &live_id, |json| { + json["lifecycleState"] == "SOLVING" || json["lifecycleState"] == "PAUSE_REQUESTED" + }) + .await; + let delete_live = request(&app, "DELETE", &format!("/jobs/{live_id}")).await; + assert_eq!(delete_live.status(), StatusCode::CONFLICT); + + let pause = request(&app, "POST", &format!("/jobs/{live_id}/pause")).await; + assert_eq!(pause.status(), StatusCode::ACCEPTED); + let _ = wait_for_status(&app, &live_id, |json| json["lifecycleState"] == "PAUSED").await; + + let delete_paused = request(&app, "DELETE", &format!("/jobs/{live_id}")).await; + assert_eq!(delete_paused.status(), StatusCode::CONFLICT); + + let resume = request(&app, "POST", &format!("/jobs/{live_id}/resume")).await; + assert_eq!(resume.status(), StatusCode::ACCEPTED); + let _ = wait_for_status(&app, &live_id, |json| { + json["lifecycleState"] == "SOLVING" || json["lifecycleState"] == "PAUSE_REQUESTED" + }) + .await; + + let cancel = request(&app, "POST", &format!("/jobs/{live_id}/cancel")).await; + assert_eq!(cancel.status(), StatusCode::ACCEPTED); + let _ = wait_for_terminal(&app, &live_id).await; + let delete_cancelled = request(&app, "DELETE", &format!("/jobs/{live_id}")).await; + assert_eq!(delete_cancelled.status(), StatusCode::NO_CONTENT); + + let mut full_ids = Vec::new(); + for _ in 0..16 { + let response = post_plan(&app, "/jobs", &heavy_plan()).await; + if response.status() != StatusCode::OK { + break; + } + let id = json_body(response).await["id"] + .as_str() + .unwrap() + .to_string(); + full_ids.push(id); + } + assert_eq!( + full_ids.len(), + 16, + "expected to occupy all 16 runtime job slots" + ); + + let full_response = post_plan(&app, "/jobs", &heavy_plan()).await; + assert_eq!(full_response.status(), StatusCode::SERVICE_UNAVAILABLE); + + for id in full_ids { + cleanup_job(&app, &id).await; + } + + let old_contract = request(&app, "POST", "/schedules").await; + assert_eq!(old_contract.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn semantically_invalid_jobs_payload_returns_bad_request_without_killing_router() { + let app = router(Arc::new(AppState::new())); + + let invalid = post_json_bytes(&app, "/jobs", br#"{}"#.to_vec()).await; + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + + let valid = post_plan(&app, "/jobs", &empty_plan()).await; + assert_eq!(valid.status(), StatusCode::OK); + + let job_id = json_body(valid).await["id"].as_str().unwrap().to_string(); + cleanup_job(&app, &job_id).await; +} diff --git a/src/api/sse.rs b/src/api/sse.rs new file mode 100644 index 0000000000000000000000000000000000000000..f43a7a254b76bd0b9312ddb6ccdc2a837eaa6592 --- /dev/null +++ b/src/api/sse.rs @@ -0,0 +1,74 @@ +//! Server-Sent Events endpoint for live solver lifecycle updates. + +use axum::{ + body::Body, + extract::{Path, State}, + http::{header, StatusCode}, + response::Response, +}; +use std::sync::Arc; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::StreamExt; + +use super::routes::AppState; + +/// Streams one bootstrap event followed by all future live job events. +pub async fn events( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + let rx = state.solver.subscribe(&id).ok_or(StatusCode::NOT_FOUND)?; + let bootstrap_json = state + .solver + .bootstrap_event(&id) + .map_err(|_| StatusCode::NOT_FOUND)?; + let bootstrap_event_sequence = event_sequence_from_json(&bootstrap_json); + // New clients first receive the latest known state so the UI can render + // immediately instead of waiting for the next live runtime event. + let bootstrap = tokio_stream::iter(std::iter::once(Ok::<_, std::convert::Infallible>( + format!("data: {}\n\n", bootstrap_json).into_bytes(), + ))); + + // After the bootstrap event we forward every future retained-job update as + // a normal SSE `data:` frame. + let live = BroadcastStream::new(rx).filter_map(move |msg| match msg { + Ok(json) => { + if event_is_not_newer(&json, bootstrap_event_sequence) { + return None; + } + Some(Ok::<_, std::convert::Infallible>( + format!("data: {}\n\n", json).into_bytes(), + )) + } + Err(_) => None, + }); + + let stream = bootstrap.chain(live); + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header("X-Accel-Buffering", "no") + .body(Body::from_stream(stream)) + .unwrap()) +} + +/// Reads lifecycle sequence metadata so bootstrap and live frames do not duplicate. +fn event_sequence_from_json(json: &str) -> Option { + serde_json::from_str::(json) + .ok() + .and_then(|value| { + value + .get("eventSequence") + .and_then(serde_json::Value::as_u64) + }) +} + +/// Drops live events already represented by the bootstrap status snapshot. +fn event_is_not_newer(json: &str, bootstrap_event_sequence: Option) -> bool { + let Some(bootstrap_event_sequence) = bootstrap_event_sequence else { + return false; + }; + event_sequence_from_json(json) + .is_some_and(|event_sequence| event_sequence <= bootstrap_event_sequence) +} diff --git a/src/constraints/assigned_shift.rs b/src/constraints/assigned_shift.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb87b7636b83306609904a0ff865226654b97be9 --- /dev/null +++ b/src/constraints/assigned_shift.rs @@ -0,0 +1,14 @@ +use crate::domain::{Plan, PlanConstraintStreams}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +const SCORE_SCALE: i64 = 100_000; + +/// Hard-penalizes each shift whose scalar planning variable is still unassigned. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .shifts() + .unassigned() + .penalize(HardSoftDecimalScore::of_hard_scaled(SCORE_SCALE)) + .named("Assigned shift") +} diff --git a/src/constraints/balance_assignments.rs b/src/constraints/balance_assignments.rs new file mode 100644 index 0000000000000000000000000000000000000000..10eecc5d642bf48ad74e2e50f763744be7e24d08 --- /dev/null +++ b/src/constraints/balance_assignments.rs @@ -0,0 +1,12 @@ +use crate::domain::{Plan, PlanConstraintStreams, Shift}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// Softly penalizes uneven distribution of assigned shifts by `employee_idx`. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .shifts() + .balance(|shift: &Shift| shift.employee_idx) + .penalize(HardSoftDecimalScore::one_soft()) + .named("Balance employee assignments") +} diff --git a/src/constraints/desired_day.rs b/src/constraints/desired_day.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d4fd8da68ecf46e7627fb6f348c9b325624fecf --- /dev/null +++ b/src/constraints/desired_day.rs @@ -0,0 +1,33 @@ +use crate::domain::{Employee, Plan, PlanConstraintStreams, Shift}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// Rewards assigning an employee to dates they explicitly prefer. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .shifts() + .filter(|shift: &Shift| shift.employee_idx.is_some()) + .join(( + ConstraintFactory::::new().employees(), + joiner::equal_bi( + |shift: &Shift| shift.employee_idx, + |employee: &Employee| Some(employee.index), + ), + )) + .filter(|shift: &Shift, employee: &Employee| { + employee + .desired_days + .iter() + .any(|date| shift.touched_dates().contains(date)) + }) + .reward(|shift: &Shift, employee: &Employee| { + HardSoftDecimalScore::of_soft( + employee + .desired_days + .iter() + .filter(|date| shift.touched_dates().contains(date)) + .count() as i64, + ) + }) + .named("Desired day for employee") +} diff --git a/src/constraints/minimum_rest.rs b/src/constraints/minimum_rest.rs new file mode 100644 index 0000000000000000000000000000000000000000..af1c672572ff078ba996889cd424cecdaee58e2d --- /dev/null +++ b/src/constraints/minimum_rest.rs @@ -0,0 +1,38 @@ +use crate::domain::{Plan, PlanConstraintStreams, Shift}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +const SCORE_SCALE: i64 = 100_000; +const STRUCTURAL_MINUTE_HARD_UNITS: i64 = 20; + +/// Hard-penalizes same-employee shift pairs separated by less than 10 hours. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .shifts() + .filter(|shift: &Shift| shift.employee_idx.is_some()) + .join(joiner::equal(|shift: &Shift| shift.employee_idx)) + .filter(|a: &Shift, b: &Shift| { + if a.index >= b.index { + return false; + } + + let (earlier, later) = if a.end <= b.start { + (a, b) + } else if b.end <= a.start { + (b, a) + } else { + return false; + }; + + let gap_minutes = (later.start - earlier.end).num_minutes(); + (0..600).contains(&gap_minutes) + }) + .penalize(hard_weight(|a: &Shift, b: &Shift| { + let (earlier, later) = if a.end <= b.start { (a, b) } else { (b, a) }; + let gap_minutes = (later.start - earlier.end).num_minutes(); + HardSoftDecimalScore::of_hard_scaled( + (600 - gap_minutes) * STRUCTURAL_MINUTE_HARD_UNITS * SCORE_SCALE, + ) + })) + .named("At least 10 hours between 2 shifts") +} diff --git a/src/constraints/mod.rs b/src/constraints/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..57851b5aef1cbea4cc68886937d56a1aa5351288 --- /dev/null +++ b/src/constraints/mod.rs @@ -0,0 +1,43 @@ +#![cfg_attr(rustfmt, rustfmt_skip)] +//! Constraint assembly for employee scheduling. +//! +//! Each sibling module contributes one named rule. `create_constraints()` +//! simply lists them in the order we want them to appear in analysis output. + +use crate::domain::Plan; +use solverforge::prelude::*; + +pub use self::assemble::create_constraints; + +// @solverforge:begin constraint-modules +mod assigned_shift; +mod required_skill; +mod overlapping_shift; +mod minimum_rest; +mod one_shift_per_day; +mod unavailable_employee; +mod undesired_day; +mod desired_day; +mod balance_assignments; +// @solverforge:end constraint-modules + +mod assemble { + use super::*; + + /// Collects the full scoring model used by `Plan`. + pub fn create_constraints() -> impl ConstraintSet { + // @solverforge:begin constraint-calls + ( + assigned_shift::constraint(), + required_skill::constraint(), + overlapping_shift::constraint(), + minimum_rest::constraint(), + one_shift_per_day::constraint(), + unavailable_employee::constraint(), + undesired_day::constraint(), + desired_day::constraint(), + balance_assignments::constraint(), + ) + // @solverforge:end constraint-calls + } +} diff --git a/src/constraints/one_shift_per_day.rs b/src/constraints/one_shift_per_day.rs new file mode 100644 index 0000000000000000000000000000000000000000..93d3b1d755c93c739ce43226b3e4eec6fa74ba23 --- /dev/null +++ b/src/constraints/one_shift_per_day.rs @@ -0,0 +1,21 @@ +use crate::domain::{Plan, PlanConstraintStreams, Shift}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +const SCORE_SCALE: i64 = 100_000; + +/// Hard-penalizes assigning two shifts that touch the same calendar day to one employee. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .shifts() + .filter(|shift: &Shift| shift.employee_idx.is_some()) + .join(joiner::equal(|shift: &Shift| shift.employee_idx)) + .filter(|a: &Shift, b: &Shift| { + a.index < b.index + && a.touched_dates() + .iter() + .any(|date| b.touched_dates().contains(date)) + }) + .penalize(HardSoftDecimalScore::of_hard_scaled(20 * SCORE_SCALE)) + .named("One shift per day") +} diff --git a/src/constraints/overlapping_shift.rs b/src/constraints/overlapping_shift.rs new file mode 100644 index 0000000000000000000000000000000000000000..8d48e580b779459b09b0aeba5f67a1134659d55d --- /dev/null +++ b/src/constraints/overlapping_shift.rs @@ -0,0 +1,28 @@ +use crate::domain::{Plan, PlanConstraintStreams, Shift}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +const SCORE_SCALE: i64 = 100_000; +const STRUCTURAL_MINUTE_HARD_UNITS: i64 = 20; + +/// Penalizes overlapping time windows for the same employee. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .shifts() + .filter(|shift: &Shift| shift.employee_idx.is_some()) + .join(joiner::equal(|shift: &Shift| shift.employee_idx)) + .filter(|a: &Shift, b: &Shift| a.index < b.index && a.start < b.end && b.start < a.end) + .penalize(hard_weight(|a: &Shift, b: &Shift| { + let overlap_start = a.start.max(b.start); + let overlap_end = a.end.min(b.end); + let overlap_minutes = if overlap_start < overlap_end { + (overlap_end - overlap_start).num_minutes() + } else { + 0 + }; + HardSoftDecimalScore::of_hard_scaled( + overlap_minutes * STRUCTURAL_MINUTE_HARD_UNITS * SCORE_SCALE, + ) + })) + .named("Overlapping shift") +} diff --git a/src/constraints/required_skill.rs b/src/constraints/required_skill.rs new file mode 100644 index 0000000000000000000000000000000000000000..3388788948cfc96076e4aa7b34ff3e3af253c49d --- /dev/null +++ b/src/constraints/required_skill.rs @@ -0,0 +1,24 @@ +use crate::domain::{Employee, Plan, PlanConstraintStreams, Shift}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +const SCORE_SCALE: i64 = 100_000; + +/// Penalizes assignments where the employee lacks the required skill label. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .shifts() + .filter(|shift: &Shift| shift.employee_idx.is_some()) + .join(( + ConstraintFactory::::new().employees(), + joiner::equal_bi( + |shift: &Shift| shift.employee_idx, + |employee: &Employee| Some(employee.index), + ), + )) + .filter(|shift: &Shift, employee: &Employee| { + !employee.skills.contains(&shift.required_skill) + }) + .penalize(HardSoftDecimalScore::of_hard_scaled(10 * SCORE_SCALE)) + .named("Required skill") +} diff --git a/src/constraints/unavailable_employee.rs b/src/constraints/unavailable_employee.rs new file mode 100644 index 0000000000000000000000000000000000000000..4f5500a1b654189b7aeb1c7cab8df5e4ae815621 --- /dev/null +++ b/src/constraints/unavailable_employee.rs @@ -0,0 +1,58 @@ +use crate::domain::{Employee, Plan, PlanConstraintStreams, Shift}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +const SCORE_SCALE: i64 = 100_000; +const STRUCTURAL_MINUTE_HARD_UNITS: i64 = 20; + +/// Hard-penalizes unavailable-date overlap, scaled by overlapping minutes. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .shifts() + .filter(|shift: &Shift| shift.employee_idx.is_some()) + .join(( + ConstraintFactory::::new().employees(), + joiner::equal_bi( + |shift: &Shift| shift.employee_idx, + |employee: &Employee| Some(employee.index), + ), + )) + .filter(|shift: &Shift, employee: &Employee| { + employee.unavailable_days.iter().any(|date| { + let day_start = date.and_hms_opt(0, 0, 0).unwrap(); + let day_end = date + .succ_opt() + .unwrap_or(*date) + .and_hms_opt(0, 0, 0) + .unwrap(); + let overlap_start = shift.start.max(day_start); + let overlap_end = shift.end.min(day_end); + overlap_start < overlap_end + }) + }) + .penalize(hard_weight(|shift: &Shift, employee: &Employee| { + let overlap_minutes: i64 = employee + .unavailable_days + .iter() + .map(|date| { + let day_start = date.and_hms_opt(0, 0, 0).unwrap(); + let day_end = date + .succ_opt() + .unwrap_or(*date) + .and_hms_opt(0, 0, 0) + .unwrap(); + let overlap_start = shift.start.max(day_start); + let overlap_end = shift.end.min(day_end); + if overlap_start < overlap_end { + (overlap_end - overlap_start).num_minutes() + } else { + 0 + } + }) + .sum(); + HardSoftDecimalScore::of_hard_scaled( + overlap_minutes * STRUCTURAL_MINUTE_HARD_UNITS * SCORE_SCALE, + ) + })) + .named("Unavailable employee") +} diff --git a/src/constraints/undesired_day.rs b/src/constraints/undesired_day.rs new file mode 100644 index 0000000000000000000000000000000000000000..dd0c47009d13453b22ee38743c9e48242bc71ddb --- /dev/null +++ b/src/constraints/undesired_day.rs @@ -0,0 +1,33 @@ +use crate::domain::{Employee, Plan, PlanConstraintStreams, Shift}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// Softly penalizes assignments that land on an employee's undesired dates. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .shifts() + .filter(|shift: &Shift| shift.employee_idx.is_some()) + .join(( + ConstraintFactory::::new().employees(), + joiner::equal_bi( + |shift: &Shift| shift.employee_idx, + |employee: &Employee| Some(employee.index), + ), + )) + .filter(|shift: &Shift, employee: &Employee| { + employee + .undesired_days + .iter() + .any(|date| shift.touched_dates().contains(date)) + }) + .penalize(|shift: &Shift, employee: &Employee| { + HardSoftDecimalScore::of_soft( + employee + .undesired_days + .iter() + .filter(|date| shift.touched_dates().contains(date)) + .count() as i64, + ) + }) + .named("Undesired day for employee") +} diff --git a/src/data/data_seed.rs b/src/data/data_seed.rs new file mode 100644 index 0000000000000000000000000000000000000000..ed6eb74c3d90629655a6a0b1648ee21d0d668c32 --- /dev/null +++ b/src/data/data_seed.rs @@ -0,0 +1,28 @@ +//! Public demo-data surface for the hospital example. +//! +//! Keep this file intentionally thin. The rest of the application imports +//! `crate::data::{generate, list_demo_data, DemoData}` as a stable boundary, so +//! the detailed dataset design lives in sibling modules where it can evolve +//! without making the top-level data surface noisy. + +mod availability; +mod cohorts; +mod coverage; +mod demand; +mod employees; +mod entrypoints; +mod large; +mod preferences; +mod shifts; +mod skills; +mod time_utils; +mod validation; +mod vocabulary; +mod witness; + +#[cfg(test)] +mod solve_tests; +#[cfg(test)] +mod tests; + +pub use entrypoints::{generate, list_demo_data, DemoData}; diff --git a/src/data/data_seed/availability.rs b/src/data/data_seed/availability.rs new file mode 100644 index 0000000000000000000000000000000000000000..a4f8429e012d86c45e2b8e901da0531112f6f52c --- /dev/null +++ b/src/data/data_seed/availability.rs @@ -0,0 +1,72 @@ +use chrono::NaiveDate; +use std::cmp::Reverse; +use std::collections::BTreeSet; + +use crate::domain::{Employee, Shift}; + +use super::coverage::{candidate_redundancy_is_valid, public_candidate_counts}; +use super::time_utils::horizon_dates; +use super::vocabulary::EXTRA_UNAVAILABLE_COUNT; + +/// Adds a small amount of extra unavailability without breaking public feasibility. +/// +/// The goal is not to make the dataset impossible. The goal is to remove some +/// trivial interchangeable assignments so local search has a clearer signal. +pub(super) fn add_extra_unavailability( + employees: &mut [Employee], + shifts: &[Shift], + witness_dates: &[BTreeSet], +) { + let horizon_dates = horizon_dates(shifts); + + for _ in 0..EXTRA_UNAVAILABLE_COUNT { + let best_candidate = (0..employees.len()) + .flat_map(|employee_index| { + horizon_dates + .iter() + .copied() + .map(move |date| (employee_index, date)) + }) + .filter(|&(employee_index, date)| { + !employees[employee_index].unavailable_dates.contains(&date) + && !witness_dates[employee_index].contains(&date) + }) + .filter_map(|(employee_index, date)| { + let score = extra_unavailability_score(employees, shifts, employee_index, date)?; + Some((score, employee_index, date)) + }) + .max_by_key(|&(score, employee_index, date)| { + (score, Reverse(employee_index), Reverse(date)) + }); + + let Some((_, employee_index, date)) = best_candidate else { + break; + }; + employees[employee_index].unavailable_dates.insert(date); + } +} + +/// Scores one candidate "employee unavailable on date" mutation. +fn extra_unavailability_score( + employees: &[Employee], + shifts: &[Shift], + employee_index: usize, + date: NaiveDate, +) -> Option<(usize, usize, usize)> { + let mut cloned: Vec = employees.to_vec(); + cloned[employee_index].unavailable_dates.insert(date); + if !candidate_redundancy_is_valid(&cloned, shifts) { + return None; + } + + let counts = public_candidate_counts(&cloned, shifts); + let affected: Vec = shifts + .iter() + .enumerate() + .filter(|(_, shift)| shift.touched_dates.contains(&date)) + .map(|(index, _)| counts[index]) + .collect(); + let min_affected = affected.into_iter().min().unwrap_or(usize::MAX); + let shifts_with_three_plus = counts.iter().filter(|&&count| count >= 3).count(); + Some((min_affected, shifts_with_three_plus, counts.iter().sum())) +} diff --git a/src/data/data_seed/cohorts.rs b/src/data/data_seed/cohorts.rs new file mode 100644 index 0000000000000000000000000000000000000000..aae963c92c057ea1414a5f71b9f716de19c27ad4 --- /dev/null +++ b/src/data/data_seed/cohorts.rs @@ -0,0 +1,198 @@ +use std::cmp::Reverse; + +use super::employees::EmployeeBlueprint; +use super::vocabulary::*; + +/// Running totals for one weekday-off cohort. +/// +/// We use this to distribute scarce specialties across the seven primary +/// off-day groups instead of accidentally clustering too many similar people on +/// the same day off. +#[derive(Default, Clone, Copy)] +struct CohortLoad { + size: usize, + doctors: usize, + nurses: usize, + ambulatory_doctors: usize, + ambulatory_nurses: usize, + neurology_doctors: usize, + neurology_nurses: usize, + critical_doctors: usize, + critical_nurses: usize, + pediatric_doctors: usize, + pediatric_nurses: usize, + surgery_doctors: usize, + surgery_nurses: usize, + outpatient_doctors: usize, + outpatient_nurses: usize, + radiology_day: usize, + radiology_nurses: usize, + radiology_call: usize, + cardiology: usize, + anaesthetics: usize, +} + +impl CohortLoad { + /// Updates the cohort totals after placing one blueprint into it. + fn add(&mut self, blueprint: &EmployeeBlueprint) { + self.size += 1; + if blueprint.skills.contains(DOCTOR) { + self.doctors += 1; + } + if blueprint.skills.contains(NURSE) { + self.nurses += 1; + } + if blueprint.skills.contains(AMBULATORY_DOCTOR) { + self.ambulatory_doctors += 1; + } + if blueprint.skills.contains(AMBULATORY_NURSE) { + self.ambulatory_nurses += 1; + } + if blueprint.skills.contains(NEUROLOGY_DOCTOR) { + self.neurology_doctors += 1; + } + if blueprint.skills.contains(NEUROLOGY_NURSE) { + self.neurology_nurses += 1; + } + if blueprint.skills.contains(CRITICAL_DOCTOR) { + self.critical_doctors += 1; + } + if blueprint.skills.contains(CRITICAL_NURSE) { + self.critical_nurses += 1; + } + if blueprint.skills.contains(PEDIATRIC_DOCTOR) { + self.pediatric_doctors += 1; + } + if blueprint.skills.contains(PEDIATRIC_NURSE) { + self.pediatric_nurses += 1; + } + if blueprint.skills.contains(SURGERY_DOCTOR) { + self.surgery_doctors += 1; + } + if blueprint.skills.contains(SURGERY_NURSE) { + self.surgery_nurses += 1; + } + if blueprint.skills.contains(OUTPATIENT_DOCTOR) { + self.outpatient_doctors += 1; + } + if blueprint.skills.contains(OUTPATIENT_NURSE) { + self.outpatient_nurses += 1; + } + if blueprint.skills.contains(RADIOLOGY_DAY) { + self.radiology_day += 1; + } + if blueprint.skills.contains(RADIOLOGY_NURSE) { + self.radiology_nurses += 1; + } + if blueprint.skills.contains(RADIOLOGY_CALL) { + self.radiology_call += 1; + } + if blueprint.skills.contains(CARDIOLOGY) { + self.cardiology += 1; + } + if blueprint.skills.contains(ANAESTHETICS) { + self.anaesthetics += 1; + } + } +} + +/// Assigns each employee blueprint a stable primary off weekday. +pub(super) fn assign_primary_off_days(blueprints: &mut [EmployeeBlueprint]) { + let mut order: Vec = (0..blueprints.len()).collect(); + order.sort_by_key(|&index| Reverse(blueprint_priority(&blueprints[index]))); + + let mut loads = [CohortLoad::default(); 7]; + + for employee_index in order { + let cohort = (0..7) + .filter(|&candidate| loads[candidate].size < PRIMARY_OFF_COHORT_SIZES[candidate]) + .min_by_key(|&candidate| cohort_score(&loads[candidate], &blueprints[employee_index])) + .expect("cohort should have spare capacity"); + blueprints[employee_index].primary_off_weekday = cohort; + loads[cohort].add(&blueprints[employee_index]); + } +} + +/// Scarcer or more specialized blueprints get placed first. +fn blueprint_priority(blueprint: &EmployeeBlueprint) -> (usize, usize, usize, usize, usize) { + ( + blueprint.specialty_count(), + usize::from(blueprint.skills.contains(DOCTOR)), + usize::from(blueprint.skills.contains(CARDIOLOGY)), + usize::from(blueprint.skills.contains(ANAESTHETICS)), + usize::from(blueprint.skills.contains(RADIOLOGY_CALL)), + ) +} + +/// Lower scores mean "this cohort needs this blueprint more". +fn cohort_score(load: &CohortLoad, blueprint: &EmployeeBlueprint) -> (usize, usize, usize, usize) { + let line_pressure = weighted_line_load(load, blueprint); + ( + line_pressure, + if blueprint.skills.contains(DOCTOR) { + load.doctors + } else { + load.nurses + }, + load.size, + blueprint.primary_off_weekday, + ) +} + +/// Applies weighted pressure so rare specialties dominate balancing decisions. +fn weighted_line_load(load: &CohortLoad, blueprint: &EmployeeBlueprint) -> usize { + let mut score = 0usize; + for (skill, weight) in line_balance_weights() { + if blueprint.has_skill(skill) { + score += line_load_for_skill(load, skill) * weight; + } + } + score +} + +/// Manual weights that treat some specialties as harder to concentrate. +fn line_balance_weights() -> &'static [(&'static str, usize)] { + &[ + (CARDIOLOGY, 8), + (RADIOLOGY_CALL, 8), + (ANAESTHETICS, 7), + (SURGERY_NURSE, 6), + (SURGERY_DOCTOR, 6), + (NEUROLOGY_DOCTOR, 6), + (RADIOLOGY_DAY, 5), + (RADIOLOGY_NURSE, 5), + (OUTPATIENT_DOCTOR, 5), + (OUTPATIENT_NURSE, 5), + (AMBULATORY_DOCTOR, 4), + (AMBULATORY_NURSE, 4), + (PEDIATRIC_DOCTOR, 4), + (PEDIATRIC_NURSE, 4), + (NEUROLOGY_NURSE, 4), + (CRITICAL_DOCTOR, 3), + (CRITICAL_NURSE, 3), + ] +} + +/// Reads the current cohort count for one specific skill. +fn line_load_for_skill(load: &CohortLoad, skill: &'static str) -> usize { + match skill { + AMBULATORY_DOCTOR => load.ambulatory_doctors, + AMBULATORY_NURSE => load.ambulatory_nurses, + NEUROLOGY_DOCTOR => load.neurology_doctors, + NEUROLOGY_NURSE => load.neurology_nurses, + CRITICAL_DOCTOR => load.critical_doctors, + CRITICAL_NURSE => load.critical_nurses, + PEDIATRIC_DOCTOR => load.pediatric_doctors, + PEDIATRIC_NURSE => load.pediatric_nurses, + SURGERY_DOCTOR => load.surgery_doctors, + SURGERY_NURSE => load.surgery_nurses, + OUTPATIENT_DOCTOR => load.outpatient_doctors, + OUTPATIENT_NURSE => load.outpatient_nurses, + RADIOLOGY_DAY => load.radiology_day, + RADIOLOGY_NURSE => load.radiology_nurses, + RADIOLOGY_CALL => load.radiology_call, + CARDIOLOGY => load.cardiology, + ANAESTHETICS => load.anaesthetics, + _ => 0, + } +} diff --git a/src/data/data_seed/coverage.rs b/src/data/data_seed/coverage.rs new file mode 100644 index 0000000000000000000000000000000000000000..4fcfe87f79125aa101a7be707728b0ce13c679a7 --- /dev/null +++ b/src/data/data_seed/coverage.rs @@ -0,0 +1,47 @@ +use chrono::Timelike; + +use crate::domain::{Employee, Shift}; + +use super::skills::is_specialty_skill; + +/// Checks that the public dataset still has enough legal candidates per shift. +pub(super) fn candidate_redundancy_is_valid(employees: &[Employee], shifts: &[Shift]) -> bool { + let counts = public_candidate_counts(employees, shifts); + if counts.iter().any(|&count| count < 2) { + return false; + } + if counts.iter().filter(|&&count| count >= 3).count() * 4 < shifts.len() { + return false; + } + if shifts.iter().zip(counts.iter()).any(|(shift, &count)| { + is_specialty_skill(&shift.required_skill) && shift.start.time().hour() == 22 && count < 2 + }) { + return false; + } + true +} + +/// Counts legal candidates per shift without considering schedule interactions. +pub(super) fn public_candidate_counts(employees: &[Employee], shifts: &[Shift]) -> Vec { + shifts + .iter() + .map(|shift| { + employees + .iter() + .filter(|employee| employee_can_cover_shift_without_schedule(employee, shift)) + .count() + }) + .collect() +} + +/// Coarse feasibility check used during dataset shaping. +pub(super) fn employee_can_cover_shift_without_schedule( + employee: &Employee, + shift: &Shift, +) -> bool { + employee.skills.contains(&shift.required_skill) + && shift + .touched_dates + .iter() + .all(|date| !employee.unavailable_dates.contains(date)) +} diff --git a/src/data/data_seed/demand.rs b/src/data/data_seed/demand.rs new file mode 100644 index 0000000000000000000000000000000000000000..768a217b1a55ae6895cf185460991ee371efa66e --- /dev/null +++ b/src/data/data_seed/demand.rs @@ -0,0 +1,273 @@ +use chrono::Weekday; + +use super::vocabulary::*; + +/// Reusable calendar patterns for demand templates. +#[derive(Clone, Copy)] +pub(super) enum WeekPattern { + Weekdays, + Weekends, + Daily, + MonWedFri, + Saturday, + Sunday, +} + +#[derive(Clone, Copy)] +pub(super) struct DemandRule { + pub(super) location: &'static str, + pub(super) start_hour: u32, + pub(super) required_skill: &'static str, + pattern: WeekPattern, + pub(super) count: usize, +} + +// This is the published demand template for the 28-day benchmark. Each rule +// says "on matching weekdays, create `count` eight-hour shifts of this shape". +pub(super) const DEMAND_RULES: &[DemandRule] = &[ + DemandRule { + location: "Ambulatory care", + start_hour: 6, + required_skill: AMBULATORY_DOCTOR, + pattern: WeekPattern::Weekdays, + count: 1, + }, + DemandRule { + location: "Ambulatory care", + start_hour: 14, + required_skill: AMBULATORY_NURSE, + pattern: WeekPattern::Weekdays, + count: 2, + }, + DemandRule { + location: "Ambulatory care", + start_hour: 6, + required_skill: AMBULATORY_DOCTOR, + pattern: WeekPattern::Weekends, + count: 1, + }, + DemandRule { + location: "Ambulatory care", + start_hour: 14, + required_skill: AMBULATORY_NURSE, + pattern: WeekPattern::Weekends, + count: 2, + }, + DemandRule { + location: "Neurology", + start_hour: 6, + required_skill: NEUROLOGY_DOCTOR, + pattern: WeekPattern::Weekdays, + count: 1, + }, + DemandRule { + location: "Neurology", + start_hour: 14, + required_skill: NEUROLOGY_NURSE, + pattern: WeekPattern::Weekdays, + count: 2, + }, + DemandRule { + location: "Neurology", + start_hour: 6, + required_skill: NEUROLOGY_DOCTOR, + pattern: WeekPattern::Weekends, + count: 1, + }, + DemandRule { + location: "Neurology", + start_hour: 14, + required_skill: NEUROLOGY_NURSE, + pattern: WeekPattern::Weekends, + count: 1, + }, + DemandRule { + location: "Neurology", + start_hour: 22, + required_skill: CARDIOLOGY, + pattern: WeekPattern::MonWedFri, + count: 1, + }, + DemandRule { + location: "Critical care", + start_hour: 6, + required_skill: CRITICAL_DOCTOR, + pattern: WeekPattern::Daily, + count: 2, + }, + DemandRule { + location: "Critical care", + start_hour: 14, + required_skill: CRITICAL_NURSE, + pattern: WeekPattern::Daily, + count: 2, + }, + DemandRule { + location: "Critical care", + start_hour: 22, + required_skill: CRITICAL_DOCTOR, + pattern: WeekPattern::Daily, + count: 1, + }, + DemandRule { + location: "Critical care", + start_hour: 9, + required_skill: CRITICAL_NURSE, + pattern: WeekPattern::Weekdays, + count: 2, + }, + DemandRule { + location: "Pediatric care", + start_hour: 6, + required_skill: PEDIATRIC_DOCTOR, + pattern: WeekPattern::Weekdays, + count: 1, + }, + DemandRule { + location: "Pediatric care", + start_hour: 14, + required_skill: PEDIATRIC_NURSE, + pattern: WeekPattern::Weekdays, + count: 2, + }, + DemandRule { + location: "Pediatric care", + start_hour: 6, + required_skill: PEDIATRIC_DOCTOR, + pattern: WeekPattern::Weekends, + count: 1, + }, + DemandRule { + location: "Pediatric care", + start_hour: 14, + required_skill: PEDIATRIC_NURSE, + pattern: WeekPattern::Weekends, + count: 2, + }, + DemandRule { + location: "Surgery", + start_hour: 6, + required_skill: SURGERY_DOCTOR, + pattern: WeekPattern::Weekdays, + count: 1, + }, + DemandRule { + location: "Surgery", + start_hour: 14, + required_skill: ANAESTHETICS, + pattern: WeekPattern::Weekdays, + count: 2, + }, + DemandRule { + location: "Surgery", + start_hour: 22, + required_skill: SURGERY_NURSE, + pattern: WeekPattern::Weekdays, + count: 1, + }, + DemandRule { + location: "Radiology", + start_hour: 6, + required_skill: RADIOLOGY_DAY, + pattern: WeekPattern::Weekdays, + count: 2, + }, + DemandRule { + location: "Radiology", + start_hour: 9, + required_skill: RADIOLOGY_DAY, + pattern: WeekPattern::Weekdays, + count: 1, + }, + DemandRule { + location: "Radiology", + start_hour: 14, + required_skill: RADIOLOGY_NURSE, + pattern: WeekPattern::Weekdays, + count: 1, + }, + DemandRule { + location: "Radiology", + start_hour: 22, + required_skill: RADIOLOGY_CALL, + pattern: WeekPattern::MonWedFri, + count: 1, + }, + DemandRule { + location: "Radiology", + start_hour: 6, + required_skill: RADIOLOGY_DAY, + pattern: WeekPattern::Saturday, + count: 1, + }, + DemandRule { + location: "Radiology", + start_hour: 9, + required_skill: RADIOLOGY_DAY, + pattern: WeekPattern::Saturday, + count: 1, + }, + DemandRule { + location: "Radiology", + start_hour: 14, + required_skill: RADIOLOGY_NURSE, + pattern: WeekPattern::Saturday, + count: 1, + }, + DemandRule { + location: "Radiology", + start_hour: 6, + required_skill: RADIOLOGY_DAY, + pattern: WeekPattern::Sunday, + count: 1, + }, + DemandRule { + location: "Radiology", + start_hour: 9, + required_skill: RADIOLOGY_DAY, + pattern: WeekPattern::Sunday, + count: 1, + }, + DemandRule { + location: "Outpatient", + start_hour: 6, + required_skill: OUTPATIENT_NURSE, + pattern: WeekPattern::Weekdays, + count: 2, + }, + DemandRule { + location: "Outpatient", + start_hour: 14, + required_skill: OUTPATIENT_DOCTOR, + pattern: WeekPattern::Weekdays, + count: 1, + }, +]; + +impl DemandRule { + /// Expands a rule into the number of shifts it contributes on a specific weekday. + pub(super) fn count_for_date(&self, weekday: Weekday) -> usize { + if self.pattern.matches(weekday) { + self.count + } else { + 0 + } + } +} + +impl WeekPattern { + /// Returns whether the abstract pattern includes the given weekday. + fn matches(self, weekday: Weekday) -> bool { + match self { + WeekPattern::Weekdays => matches!( + weekday, + Weekday::Mon | Weekday::Tue | Weekday::Wed | Weekday::Thu | Weekday::Fri + ), + WeekPattern::Weekends => matches!(weekday, Weekday::Sat | Weekday::Sun), + WeekPattern::Daily => true, + WeekPattern::MonWedFri => matches!(weekday, Weekday::Mon | Weekday::Wed | Weekday::Fri), + WeekPattern::Saturday => weekday == Weekday::Sat, + WeekPattern::Sunday => weekday == Weekday::Sun, + } + } +} diff --git a/src/data/data_seed/employees.rs b/src/data/data_seed/employees.rs new file mode 100644 index 0000000000000000000000000000000000000000..cdc3e4ea1241b666250d87e03f95afe43867ab1e --- /dev/null +++ b/src/data/data_seed/employees.rs @@ -0,0 +1,150 @@ +use chrono::{Duration, NaiveDate}; +use rand::rngs::StdRng; +use std::collections::BTreeSet; + +use crate::domain::{CareHub, Employee}; + +use super::time_utils::generate_name_permutations; +use super::vocabulary::*; + +/// Draft workforce record used before we instantiate full `Employee` facts. +#[derive(Clone)] +pub(super) struct EmployeeBlueprint { + pub(super) name: String, + pub(super) skills: BTreeSet, + pub(super) home_hub: CareHub, + pub(super) primary_off_weekday: usize, +} + +/// Builds the fixed workforce composition for the public demo dataset. +pub(super) fn build_employee_blueprints(rng: &mut StdRng) -> Vec { + let names = generate_name_permutations(rng); + let mut skill_sets: Vec> = Vec::with_capacity(EMPLOYEE_COUNT); + + // The generator used to hand almost every day shift to a generic Doctor or + // Nurse pool. That made most legal assignments interchangeable and flattened + // local search almost immediately. The redesign keeps the same workforce + // size, but assigns each employee to one or two service lines so every + // shift has a smaller, more meaningful candidate set. + // + // We still retain the base DOCTOR/NURSE tags so the witness builder can + // reason about role families, but public shifts now require service-line + // skills such as `Critical care doctor` or `Outpatient nurse`. + push_skill_sets(&mut skill_sets, 4, &[DOCTOR, CRITICAL_DOCTOR]); + push_skill_sets( + &mut skill_sets, + 2, + &[DOCTOR, CRITICAL_DOCTOR, OUTPATIENT_DOCTOR], + ); + push_skill_sets(&mut skill_sets, 4, &[DOCTOR, NEUROLOGY_DOCTOR, CARDIOLOGY]); + push_skill_sets( + &mut skill_sets, + 3, + &[DOCTOR, AMBULATORY_DOCTOR, PEDIATRIC_DOCTOR], + ); + push_skill_sets(&mut skill_sets, 4, &[DOCTOR, SURGERY_DOCTOR, ANAESTHETICS]); + push_skill_sets( + &mut skill_sets, + 1, + &[DOCTOR, OUTPATIENT_DOCTOR, AMBULATORY_DOCTOR], + ); + push_skill_sets( + &mut skill_sets, + 4, + &[DOCTOR, RADIOLOGY_CALL, OUTPATIENT_DOCTOR], + ); + + push_skill_sets(&mut skill_sets, 5, &[NURSE, CRITICAL_NURSE]); + push_skill_sets( + &mut skill_sets, + 3, + &[NURSE, CRITICAL_NURSE, OUTPATIENT_NURSE], + ); + push_skill_sets( + &mut skill_sets, + 4, + &[NURSE, AMBULATORY_NURSE, PEDIATRIC_NURSE], + ); + push_skill_sets( + &mut skill_sets, + 4, + &[NURSE, NEUROLOGY_NURSE, PEDIATRIC_NURSE], + ); + push_skill_sets( + &mut skill_sets, + 4, + &[NURSE, SURGERY_NURSE, OUTPATIENT_NURSE], + ); + push_skill_sets(&mut skill_sets, 4, &[NURSE, RADIOLOGY_DAY, RADIOLOGY_NURSE]); + push_skill_sets( + &mut skill_sets, + 2, + &[NURSE, RADIOLOGY_DAY, RADIOLOGY_NURSE, ANAESTHETICS], + ); + push_skill_sets( + &mut skill_sets, + 2, + &[NURSE, AMBULATORY_NURSE, OUTPATIENT_NURSE], + ); + + assert_eq!( + skill_sets.len(), + EMPLOYEE_COUNT, + "employee blueprint count should match workforce target" + ); + + skill_sets + .into_iter() + .enumerate() + .map(|(index, skills)| { + let home_hub = CareHub::infer_from_skills(skills.iter().copied()); + EmployeeBlueprint { + name: names[index].clone(), + skills: skills.into_iter().map(str::to_string).collect(), + home_hub, + primary_off_weekday: 0, + } + }) + .collect() +} + +/// Appends `count` identical skill bundles to the blueprint list. +fn push_skill_sets(target: &mut Vec>, count: usize, skills: &[&'static str]) { + for _ in 0..count { + target.push(skills.to_vec()); + } +} + +impl EmployeeBlueprint { + /// Tiny convenience helper used by balancing heuristics. + pub(super) fn has_skill(&self, skill: &'static str) -> bool { + self.skills.contains(skill) + } + + /// Counts the specialties that are intentionally scarce in this dataset. + pub(super) fn specialty_count(&self) -> usize { + usize::from(self.skills.contains(CARDIOLOGY)) + + usize::from(self.skills.contains(ANAESTHETICS)) + + usize::from(self.skills.contains(RADIOLOGY_CALL)) + + usize::from(self.skills.contains(RADIOLOGY_DAY)) + } +} + +/// Turns the blueprints into the actual `Employee` facts published by the app. +pub(super) fn instantiate_employees( + blueprints: &[EmployeeBlueprint], + start_date: NaiveDate, +) -> Vec { + let mut employees = Vec::with_capacity(blueprints.len()); + for (index, blueprint) in blueprints.iter().enumerate() { + let mut employee = Employee::new(index, blueprint.name.clone()) + .with_home_hub(blueprint.home_hub) + .with_skills(blueprint.skills.iter().map(|skill| skill.as_str())); + for week in 0..(DAYS_IN_SCHEDULE / 7) { + let date = start_date + Duration::days(week * 7 + blueprint.primary_off_weekday as i64); + employee.unavailable_dates.insert(date); + } + employees.push(employee); + } + employees +} diff --git a/src/data/data_seed/entrypoints.rs b/src/data/data_seed/entrypoints.rs new file mode 100644 index 0000000000000000000000000000000000000000..9b20b16c296bfd5f82389dcf0c7ed705bca772da --- /dev/null +++ b/src/data/data_seed/entrypoints.rs @@ -0,0 +1,52 @@ +use std::str::FromStr; + +use crate::domain::Plan; + +use super::large::generate_large; + +/// Public demo-data identifiers exposed through the HTTP API. +/// +/// The hospital app currently ships one serious benchmark instance rather than a +/// menu of toy presets, so the surface stays explicit instead of pretending that +/// multiple sizes exist when they do not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DemoData { + Large, +} + +impl FromStr for DemoData { + type Err = (); + + /// Parses the case-insensitive demo id exposed over HTTP. + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "LARGE" => Ok(DemoData::Large), + _ => Err(()), + } + } +} + +impl DemoData { + /// Returns the canonical uppercase id used by the HTTP API. + pub fn as_str(&self) -> &'static str { + match self { + DemoData::Large => "LARGE", + } + } +} + +/// Lists the demo identifiers accepted by `/demo-data/{id}`. +pub fn list_demo_data() -> Vec<&'static str> { + vec![DemoData::Large.as_str()] +} + +/// Generates the requested demo dataset. +/// +/// Dispatch stays here so callers see the supported public variants in one +/// place, while the dataset assembly itself remains hidden in the per-instance +/// modules. +pub fn generate(demo: DemoData) -> Plan { + match demo { + DemoData::Large => generate_large(), + } +} diff --git a/src/data/data_seed/large.rs b/src/data/data_seed/large.rs new file mode 100644 index 0000000000000000000000000000000000000000..e1b171d8e7aea17b0aa51af17a11fc78c3353341 --- /dev/null +++ b/src/data/data_seed/large.rs @@ -0,0 +1,59 @@ +use std::sync::OnceLock; + +use chrono::NaiveDate; +use rand::rngs::StdRng; +use rand::SeedableRng; + +use crate::domain::Plan; + +use super::availability::add_extra_unavailability; +use super::cohorts::assign_primary_off_days; +use super::employees::{build_employee_blueprints, instantiate_employees}; +use super::preferences::add_preferences; +use super::shifts::{build_public_shifts, prepare_shifts}; +use super::time_utils::find_next_monday; +use super::validation::validate_public_dataset; +use super::witness::build_hidden_witness; + +/// Materializes the canonical hospital benchmark dataset. +/// +/// We cache the built plan because demo data is immutable and deterministic. +/// Reusing the same constructed instance avoids paying generator cost on every +/// API request while still returning an owned `Plan` to each caller. +pub fn generate_large() -> Plan { + static SCHEDULE: OnceLock = OnceLock::new(); + SCHEDULE.get_or_init(build_large_schedule).clone() +} + +/// Builds the single published benchmark instance from scratch. +fn build_large_schedule() -> Plan { + let mut rng = StdRng::seed_from_u64(0); + let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()); + + // Workforce blueprints are the stable source of truth for skill mix and + // cohort identity. We shape off-days at the blueprint level so the later + // instantiated employees inherit the intended coverage structure. + let mut blueprints = build_employee_blueprints(&mut rng); + assign_primary_off_days(&mut blueprints); + + // The public problem is what the solver sees: employees plus currently + // unassigned shifts. We construct that surface before adding preference + // pressure so all later shaping is anchored to the real published dataset. + let mut employees = instantiate_employees(&blueprints, start_date); + let mut shifts = build_public_shifts(start_date); + prepare_shifts(&mut shifts); + + // The witness roster is the generator's internal "known feasible" schedule. + // We never expose it to the solver. We use it only to shape calendars and + // preferences so the public problem stays feasible while still containing + // soft-pressure opportunities that construction does not get for free. + let witness = build_hidden_witness(&employees, &shifts); + add_extra_unavailability(&mut employees, &shifts, &witness.employee_touched_dates); + add_preferences(&mut employees, start_date, &blueprints, &shifts, &witness); + + // Validation is the last step on purpose: it checks the exact public dataset + // that the API will serve rather than an earlier intermediate state. + validate_public_dataset(&employees, &shifts); + + Plan::new(employees, shifts) +} diff --git a/src/data/data_seed/preferences.rs b/src/data/data_seed/preferences.rs new file mode 100644 index 0000000000000000000000000000000000000000..82ecc71b0d77a8facfa614095aeacbaf24ffd4cc --- /dev/null +++ b/src/data/data_seed/preferences.rs @@ -0,0 +1,190 @@ +//! Preference shaping for the public dataset. +//! +//! The hidden witness gives us a hard-feasible schedule. This module then adds +//! desired/undesired dates so the public problem contains soft-score movement +//! without throwing away that feasibility margin. + +mod exchange; +mod floor; +mod support; +mod top_up; + +use chrono::NaiveDate; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::domain::{Employee, Shift}; + +use self::exchange::assign_exchange_preferences; +use self::floor::ensure_preference_floor; +use self::top_up::{add_weekend_preference_bias, top_up_preferences}; +use super::coverage::employee_can_cover_shift_without_schedule; +use super::employees::EmployeeBlueprint; +use super::vocabulary::{EXCHANGE_MARK_LIMIT_PER_EMPLOYEE, MAX_DESIRED_DATES, MAX_UNDESIRED_DATES}; +use super::witness::WitnessRoster; + +#[cfg(test)] +pub(super) fn shift_soft_preference_score(employee: &Employee, shift: &Shift) -> i64 { + support::shift_soft_preference_score(employee, shift) +} + +/// Adds the full preference surface for the public dataset. +pub(super) fn add_preferences( + employees: &mut [Employee], + start_date: NaiveDate, + blueprints: &[EmployeeBlueprint], + shifts: &[Shift], + witness: &WitnessRoster, +) { + let analysis = PreferenceAnalysis::build(employees, shifts, witness); + + // Phase 1: create direct witness-relative exchange pressure. + // + // For a curated subset of shifts we mark the witness holder as disliking the + // touched date and mark one feasible alternative as preferring it. This makes + // the hidden witness intentionally non-soft-optimal and, more importantly, + // creates real one-move improvement opportunities in the public solution + // space instead of generic weekday-themed noise. + if let Some(max_exchange_marks_per_employee) = EXCHANGE_MARK_LIMIT_PER_EMPLOYEE { + assign_exchange_preferences( + employees, + blueprints, + shifts, + witness, + &analysis, + max_exchange_marks_per_employee, + ); + } + + // Phase 2: fill the remaining preference volume with stable weekday-themed + // dates. The pure witness-relative variant created a richer soft surface, + // but it also made cheapest-insertion too eager to burn hard feasibility. + // The hybrid shape keeps one explicit exchange signal while leaving the + // bulk of the dataset in a feasibility-friendly, deterministic pattern. + top_up_preferences(employees, start_date, blueprints); + + // Phase 3: add a tiny weekend bias only when the employee is still below the + // target preference floor. This keeps the designed feasibility margin while + // still breaking some of the largest weekday-only symmetries. + add_weekend_preference_bias(employees, start_date, blueprints); + + ensure_preference_floor( + employees, + &witness.employee_touched_dates, + &analysis.coverable_dates_by_employee, + &analysis.date_pressure, + ); + + for employee in employees.iter() { + assert!( + employee.desired_dates.len() >= 4 && employee.desired_dates.len() <= MAX_DESIRED_DATES, + "desired-date volume should stay in the designed band" + ); + assert!( + employee.undesired_dates.len() >= 4 + && employee.undesired_dates.len() <= MAX_UNDESIRED_DATES, + "undesired-date volume should stay in the designed band" + ); + assert!( + employee + .desired_dates + .iter() + .all(|date| !employee.undesired_dates.contains(date)), + "desired and undesired dates must stay disjoint" + ); + } +} + +/// Cached facts shared by the different preference-shaping passes. +struct PreferenceAnalysis { + candidate_lists: Vec>, + candidate_counts: Vec, + witness_shifts_by_employee: Vec>, + coverable_dates_by_employee: Vec>, + date_pressure: BTreeMap, +} + +impl PreferenceAnalysis { + /// Precomputes the helper views every preference phase needs. + fn build(employees: &[Employee], shifts: &[Shift], witness: &WitnessRoster) -> Self { + let candidate_lists = eligible_employees_by_shift(employees, shifts); + let candidate_counts: Vec = candidate_lists.iter().map(Vec::len).collect(); + let witness_shifts_by_employee = + witness_shift_indices_by_employee(&witness.assignments, employees.len()); + let coverable_dates_by_employee = + coverable_dates_by_employee(&candidate_lists, shifts, employees.len()); + let date_pressure = date_pressure_by_day(shifts, &candidate_counts); + + Self { + candidate_lists, + candidate_counts, + witness_shifts_by_employee, + coverable_dates_by_employee, + date_pressure, + } + } +} + +/// Lists the legal employees for each public shift before scheduling interactions. +pub(super) fn eligible_employees_by_shift( + employees: &[Employee], + shifts: &[Shift], +) -> Vec> { + shifts + .iter() + .map(|shift| { + employees + .iter() + .enumerate() + .filter(|(_, employee)| employee_can_cover_shift_without_schedule(employee, shift)) + .map(|(employee_index, _)| employee_index) + .collect() + }) + .collect() +} + +/// Reverses witness assignments into "which shifts belong to each employee". +fn witness_shift_indices_by_employee( + assignments: &[usize], + employee_count: usize, +) -> Vec> { + let mut shifts_by_employee = vec![Vec::new(); employee_count]; + for (shift_index, &employee_index) in assignments.iter().enumerate() { + shifts_by_employee[employee_index].push(shift_index); + } + shifts_by_employee +} + +/// Collects every date an employee could legally cover in the public dataset. +fn coverable_dates_by_employee( + candidate_lists: &[Vec], + shifts: &[Shift], + employee_count: usize, +) -> Vec> { + let mut dates_by_employee = vec![BTreeSet::new(); employee_count]; + for (shift_index, candidates) in candidate_lists.iter().enumerate() { + for &candidate in candidates { + dates_by_employee[candidate].extend(shifts[shift_index].touched_dates.iter().copied()); + } + } + dates_by_employee +} + +/// Scores dates by how much soft-pressure they can safely carry. +fn date_pressure_by_day( + shifts: &[Shift], + candidate_counts: &[usize], +) -> BTreeMap { + let mut pressure = BTreeMap::new(); + for (shift, &candidate_count) in shifts.iter().zip(candidate_counts.iter()) { + // The goal here is not to make the hard problem tighter. It is to create + // lots of feasible reassignment opportunities. We therefore score dates + // by how many "comfortable" alternatives they carry, not by how scarce + // they are. Scarce dates belong to feasibility; abundant dates are where + // soft pressure can live without poisoning construction. + let shift_pressure = candidate_count.clamp(2, 8) - 1; + for &date in &shift.touched_dates { + *pressure.entry(date).or_default() += shift_pressure.max(1); + } + } + pressure +} diff --git a/src/data/data_seed/preferences/exchange.rs b/src/data/data_seed/preferences/exchange.rs new file mode 100644 index 0000000000000000000000000000000000000000..f65e7d5d64cdd1cc46dfcc1ee3813b14e0180246 --- /dev/null +++ b/src/data/data_seed/preferences/exchange.rs @@ -0,0 +1,102 @@ +use chrono::Timelike; +use std::cmp::Reverse; + +use crate::domain::{Employee, Shift}; + +use super::support::{ + can_mark_preference_date, date_pressure_for_shift, mark_preference_date, preferred_shift_date, + shift_prefers_doctor_family, shift_same_shape, PreferenceKind, +}; +use super::PreferenceAnalysis; +use crate::data::data_seed::employees::EmployeeBlueprint; +use crate::data::data_seed::skills::is_specialty_skill; +use crate::data::data_seed::vocabulary::DOCTOR; +use crate::data::data_seed::witness::{shift_priority_rank, WitnessRoster}; + +/// Adds a small number of witness-relative preference swaps. +/// +/// This is the sharpest source of local-search signal in the dataset: one +/// employee is marked as disliking a date while another feasible employee is +/// marked as preferring it. +pub(super) fn assign_exchange_preferences( + employees: &mut [Employee], + blueprints: &[EmployeeBlueprint], + shifts: &[Shift], + witness: &WitnessRoster, + analysis: &PreferenceAnalysis, + max_exchange_marks_per_employee: usize, +) { + let mut exchange_marks_by_employee = vec![0usize; employees.len()]; + let mut shift_order: Vec = (0..shifts.len()).collect(); + shift_order.sort_by_key(|&shift_index| { + let shift = &shifts[shift_index]; + ( + Reverse(analysis.candidate_counts[shift_index]), + Reverse(date_pressure_for_shift(shift, &analysis.date_pressure)), + shift_priority_rank(shift), + shift.start, + shift_index, + ) + }); + + for shift_index in shift_order { + let shift = &shifts[shift_index]; + if analysis.candidate_counts[shift_index] < 6 + || is_specialty_skill(&shift.required_skill) + || shift.start.time().hour() == 22 + { + continue; + } + let holder = witness.assignments[shift_index]; + let date = preferred_shift_date(shift, &analysis.date_pressure); + + let Some(alternative) = analysis.candidate_lists[shift_index] + .iter() + .copied() + .filter(|&candidate| candidate != holder) + .filter(|&candidate| { + exchange_marks_by_employee[candidate] < max_exchange_marks_per_employee + }) + .filter(|&candidate| { + can_mark_preference_date(&employees[candidate], date, PreferenceKind::Desired) + }) + .min_by_key(|&candidate| { + exchange_alternative_key(candidate, shift, shifts, blueprints, witness, analysis) + }) + else { + continue; + }; + + if mark_preference_date(&mut employees[alternative], date, PreferenceKind::Desired) { + exchange_marks_by_employee[alternative] += 1; + } + } +} + +/// Lower keys mean "better alternative employee for this exchange mark". +fn exchange_alternative_key( + candidate: usize, + shift: &Shift, + shifts: &[Shift], + blueprints: &[EmployeeBlueprint], + witness: &WitnessRoster, + analysis: &PreferenceAnalysis, +) -> (usize, usize, usize, usize, usize, usize) { + let date = preferred_shift_date(shift, &analysis.date_pressure); + let same_date_in_witness = + usize::from(witness.employee_touched_dates[candidate].contains(&date)); + let same_shape_load = analysis.witness_shifts_by_employee[candidate] + .iter() + .filter(|&&other_shift_index| shift_same_shape(shift, &shifts[other_shift_index])) + .count(); + ( + same_date_in_witness, + usize::from( + blueprints[candidate].skills.contains(DOCTOR) != shift_prefers_doctor_family(shift), + ), + same_shape_load, + witness.employee_touched_dates[candidate].len(), + usize::MAX - *analysis.date_pressure.get(&date).unwrap_or(&0), + candidate, + ) +} diff --git a/src/data/data_seed/preferences/floor.rs b/src/data/data_seed/preferences/floor.rs new file mode 100644 index 0000000000000000000000000000000000000000..7c6db581052a60fe65f331d66a8697e3abee5cbd --- /dev/null +++ b/src/data/data_seed/preferences/floor.rs @@ -0,0 +1,76 @@ +use chrono::NaiveDate; +use std::cmp::Reverse; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::domain::Employee; + +use super::support::{can_mark_preference_date, mark_preference_date, PreferenceKind}; + +/// Ensures every employee ends up with the minimum amount of preference signal. +pub(super) fn ensure_preference_floor( + employees: &mut [Employee], + witness_dates_by_employee: &[BTreeSet], + coverable_dates_by_employee: &[BTreeSet], + date_pressure: &BTreeMap, +) { + for employee_index in 0..employees.len() { + while employees[employee_index].undesired_dates.len() < 4 { + let candidate = witness_dates_by_employee[employee_index] + .iter() + .chain(coverable_dates_by_employee[employee_index].iter()) + .copied() + .filter(|&date| { + can_mark_preference_date( + &employees[employee_index], + date, + PreferenceKind::Undesired, + ) + }) + .max_by_key(|date| (*date_pressure.get(date).unwrap_or(&0), Reverse(*date))); + let Some(date) = candidate else { + break; + }; + let _ = mark_preference_date( + &mut employees[employee_index], + date, + PreferenceKind::Undesired, + ); + } + + while employees[employee_index].desired_dates.len() < 4 { + let candidate = coverable_dates_by_employee[employee_index] + .iter() + .copied() + .filter(|&date| !witness_dates_by_employee[employee_index].contains(&date)) + .filter(|&date| { + can_mark_preference_date( + &employees[employee_index], + date, + PreferenceKind::Desired, + ) + }) + .max_by_key(|date| (*date_pressure.get(date).unwrap_or(&0), Reverse(*date))) + .or_else(|| { + coverable_dates_by_employee[employee_index] + .iter() + .copied() + .filter(|&date| { + can_mark_preference_date( + &employees[employee_index], + date, + PreferenceKind::Desired, + ) + }) + .max_by_key(|date| (*date_pressure.get(date).unwrap_or(&0), Reverse(*date))) + }); + let Some(date) = candidate else { + break; + }; + let _ = mark_preference_date( + &mut employees[employee_index], + date, + PreferenceKind::Desired, + ); + } + } +} diff --git a/src/data/data_seed/preferences/support.rs b/src/data/data_seed/preferences/support.rs new file mode 100644 index 0000000000000000000000000000000000000000..815f6c1f63778e93b3fe1730e6af08438582cf60 --- /dev/null +++ b/src/data/data_seed/preferences/support.rs @@ -0,0 +1,103 @@ +use chrono::{NaiveDate, Timelike}; +use std::cmp::Reverse; +use std::collections::BTreeMap; + +use crate::domain::{Employee, Shift}; + +use crate::data::data_seed::skills::is_doctor_family_skill; +use crate::data::data_seed::vocabulary::{MAX_DESIRED_DATES, MAX_UNDESIRED_DATES}; + +#[derive(Clone, Copy)] +pub(super) enum PreferenceKind { + Desired, + Undesired, +} + +/// Returns whether a preference mark can be added without breaking the rules. +pub(super) fn can_mark_preference_date( + employee: &Employee, + date: NaiveDate, + kind: PreferenceKind, +) -> bool { + if employee.unavailable_dates.contains(&date) { + return false; + } + match kind { + PreferenceKind::Desired => { + employee.desired_dates.len() < MAX_DESIRED_DATES + && !employee.desired_dates.contains(&date) + && !employee.undesired_dates.contains(&date) + } + PreferenceKind::Undesired => { + employee.undesired_dates.len() < MAX_UNDESIRED_DATES + && !employee.undesired_dates.contains(&date) + && !employee.desired_dates.contains(&date) + } + } +} + +/// Mutates the employee by adding a desired or undesired date when legal. +pub(super) fn mark_preference_date( + employee: &mut Employee, + date: NaiveDate, + kind: PreferenceKind, +) -> bool { + if !can_mark_preference_date(employee, date, kind) { + return false; + } + match kind { + PreferenceKind::Desired => employee.desired_dates.insert(date), + PreferenceKind::Undesired => employee.undesired_dates.insert(date), + } +} + +/// Chooses the most "pressure-carrying" date touched by a shift. +pub(super) fn preferred_shift_date( + shift: &Shift, + date_pressure: &BTreeMap, +) -> NaiveDate { + shift + .touched_dates + .iter() + .copied() + .max_by_key(|date| (*date_pressure.get(date).unwrap_or(&0), Reverse(*date))) + .expect("shift should touch at least one date") +} + +/// Returns the precomputed pressure score for the date chosen above. +pub(super) fn date_pressure_for_shift( + shift: &Shift, + date_pressure: &BTreeMap, +) -> usize { + let date = preferred_shift_date(shift, date_pressure); + *date_pressure + .get(&date) + .expect("preferred shift date should have a pressure score") +} + +/// Small domain helper used while choosing exchange-preference targets. +pub(super) fn shift_prefers_doctor_family(shift: &Shift) -> bool { + is_doctor_family_skill(&shift.required_skill) +} + +/// Treats two shifts as the same broad "shape" for preference balancing. +pub(super) fn shift_same_shape(left: &Shift, right: &Shift) -> bool { + left.required_skill == right.required_skill + && (left.start.time().hour() == 22) == (right.start.time().hour() == 22) +} + +#[cfg(test)] +/// Test helper for checking whether a move changes the preference score. +pub(super) fn shift_soft_preference_score(employee: &Employee, shift: &Shift) -> i64 { + let desired_matches = employee + .desired_dates + .iter() + .filter(|date| shift.touched_dates.contains(date)) + .count() as i64; + let undesired_matches = employee + .undesired_dates + .iter() + .filter(|date| shift.touched_dates.contains(date)) + .count() as i64; + desired_matches - undesired_matches +} diff --git a/src/data/data_seed/preferences/top_up.rs b/src/data/data_seed/preferences/top_up.rs new file mode 100644 index 0000000000000000000000000000000000000000..7eb18d219fd2b5aa49fdb9dc5e23c775347435b5 --- /dev/null +++ b/src/data/data_seed/preferences/top_up.rs @@ -0,0 +1,115 @@ +use chrono::{NaiveDate, Weekday}; + +use crate::domain::Employee; + +use super::support::{can_mark_preference_date, mark_preference_date, PreferenceKind}; +use crate::data::data_seed::employees::EmployeeBlueprint; +use crate::data::data_seed::time_utils::{choose_weekday_with_four_available_dates, weekday_dates}; +use crate::data::data_seed::vocabulary::{TARGET_DESIRED_DATES, TARGET_UNDESIRED_DATES}; + +/// Fills the remaining preference slots with stable weekday-themed dates. +pub(super) fn top_up_preferences( + employees: &mut [Employee], + start_date: NaiveDate, + blueprints: &[EmployeeBlueprint], +) { + let dates_by_weekday = weekday_dates(start_date); + + for (employee_index, employee) in employees.iter_mut().enumerate() { + let blueprint = &blueprints[employee_index]; + let primary_off = blueprint.primary_off_weekday; + + let preferred_weekday = choose_weekday_with_four_available_dates( + &employee.unavailable_dates, + primary_off, + (0..7).map(|offset| (primary_off + 2 + offset) % 7), + ); + let undesired_weekday = choose_weekday_with_four_available_dates( + &employee.unavailable_dates, + primary_off, + (0..7).map(|offset| { + let candidate = (primary_off + 4 + offset) % 7; + if candidate == preferred_weekday { + (candidate + 1) % 7 + } else { + candidate + } + }), + ); + + for &date in &dates_by_weekday[preferred_weekday] { + if employee.desired_dates.len() >= TARGET_DESIRED_DATES { + break; + } + let _ = mark_preference_date(employee, date, PreferenceKind::Desired); + } + + for &date in &dates_by_weekday[undesired_weekday] { + if employee.undesired_dates.len() >= TARGET_UNDESIRED_DATES { + break; + } + let _ = mark_preference_date(employee, date, PreferenceKind::Undesired); + } + } +} + +/// Adds a tiny weekend bias to break some weekday-only symmetry. +pub(super) fn add_weekend_preference_bias( + employees: &mut [Employee], + start_date: NaiveDate, + blueprints: &[EmployeeBlueprint], +) { + let dates_by_weekday = weekday_dates(start_date); + let saturdays = &dates_by_weekday[Weekday::Sat.num_days_from_monday() as usize]; + let sundays = &dates_by_weekday[Weekday::Sun.num_days_from_monday() as usize]; + + for employee_index in 0..employees.len() { + let weekend_mode = (blueprints[employee_index].specialty_count() + + blueprints[employee_index].primary_off_weekday) + % 3; + + match weekend_mode { + 0 if employees[employee_index].desired_dates.len() < TARGET_DESIRED_DATES => { + if let Some(date) = saturdays + .iter() + .chain(sundays.iter()) + .copied() + .find(|&date| { + can_mark_preference_date( + &employees[employee_index], + date, + PreferenceKind::Desired, + ) + }) + { + let _ = mark_preference_date( + &mut employees[employee_index], + date, + PreferenceKind::Desired, + ); + } + } + 1 if employees[employee_index].undesired_dates.len() < TARGET_UNDESIRED_DATES => { + if let Some(date) = saturdays + .iter() + .chain(sundays.iter()) + .copied() + .find(|&date| { + can_mark_preference_date( + &employees[employee_index], + date, + PreferenceKind::Undesired, + ) + }) + { + let _ = mark_preference_date( + &mut employees[employee_index], + date, + PreferenceKind::Undesired, + ); + } + } + _ => {} + } + } +} diff --git a/src/data/data_seed/shifts.rs b/src/data/data_seed/shifts.rs new file mode 100644 index 0000000000000000000000000000000000000000..dc5f1e07c36e1ca4fd75caeba5f22884912d05c0 --- /dev/null +++ b/src/data/data_seed/shifts.rs @@ -0,0 +1,54 @@ +use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime}; + +use crate::domain::Shift; + +use super::demand::DEMAND_RULES; +use super::time_utils::{dates_touched_by_span, find_next_monday, time}; +use super::vocabulary::DAYS_IN_SCHEDULE; + +/// Expands the demand template into the actual public shift entities. +pub(super) fn build_public_shifts(start_date: NaiveDate) -> Vec { + let mut shifts = Vec::with_capacity(expected_shift_count()); + let mut shift_id = 0usize; + + for day in 0..DAYS_IN_SCHEDULE { + let date = start_date + Duration::days(day); + for rule in DEMAND_RULES { + for _ in 0..rule.count_for_date(date.weekday()) { + let start = NaiveDateTime::new(date, time(rule.start_hour, 0)); + let end = start + Duration::hours(8); + shifts.push(Shift::new( + shift_id.to_string(), + start, + end, + rule.location, + rule.required_skill, + )); + shift_id += 1; + } + } + } + + shifts +} + +/// Fills derived shift fields after the raw templates are materialized. +pub(super) fn prepare_shifts(shifts: &mut [Shift]) { + for (index, shift) in shifts.iter_mut().enumerate() { + shift.index = index; + shift.touched_dates = dates_touched_by_span(shift.start, shift.end); + } +} + +/// Recomputes the expected number of public shifts from the demand template. +pub(super) fn expected_shift_count() -> usize { + let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()); + let mut count = 0usize; + for day in 0..DAYS_IN_SCHEDULE { + let date = start_date + Duration::days(day); + for rule in DEMAND_RULES { + count += rule.count_for_date(date.weekday()); + } + } + count +} diff --git a/src/data/data_seed/skills.rs b/src/data/data_seed/skills.rs new file mode 100644 index 0000000000000000000000000000000000000000..ea2ca1d4b43794b56e25f60d6bb2dfe46386b77c --- /dev/null +++ b/src/data/data_seed/skills.rs @@ -0,0 +1,42 @@ +use super::vocabulary::*; + +/// Returns whether the skill is one of the scarcer specialty signals. +pub(super) fn is_specialty_skill(skill: &str) -> bool { + matches!( + skill, + CARDIOLOGY | ANAESTHETICS | RADIOLOGY_DAY | RADIOLOGY_CALL + ) +} + +/// Groups service-line skills under the broad "doctor-family" umbrella. +pub(super) fn is_doctor_family_skill(skill: &str) -> bool { + matches!( + skill, + DOCTOR + | AMBULATORY_DOCTOR + | NEUROLOGY_DOCTOR + | CRITICAL_DOCTOR + | PEDIATRIC_DOCTOR + | SURGERY_DOCTOR + | OUTPATIENT_DOCTOR + | RADIOLOGY_CALL + | CARDIOLOGY + | ANAESTHETICS + ) +} + +/// Groups service-line skills under the broad "nurse-family" umbrella. +pub(super) fn is_nurse_family_skill(skill: &str) -> bool { + matches!( + skill, + NURSE + | AMBULATORY_NURSE + | NEUROLOGY_NURSE + | CRITICAL_NURSE + | PEDIATRIC_NURSE + | SURGERY_NURSE + | OUTPATIENT_NURSE + | RADIOLOGY_NURSE + | RADIOLOGY_DAY + ) +} diff --git a/src/data/data_seed/solve_tests.rs b/src/data/data_seed/solve_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..40d08c95851d3a0cc6f511b769f90d50a878ee38 --- /dev/null +++ b/src/data/data_seed/solve_tests.rs @@ -0,0 +1,75 @@ +use chrono::Timelike; +use solverforge::{ConstraintSet, SolverEvent, SolverManager}; +use std::collections::BTreeMap; + +use super::{generate, DemoData}; +use crate::domain::Plan; + +// Slow end-to-end acceptance test for the published benchmark instance. + +fn schedule() -> Plan { + generate(DemoData::Large) +} + +#[test] +#[ignore = "slow acceptance test for the canonical quickstart dataset"] +fn large_demo_solves_to_feasible_terminal_state() { + static MANAGER: SolverManager = SolverManager::new(); + + let schedule = schedule(); + let (job_id, mut receiver) = MANAGER.solve(schedule).expect("job should start"); + let mut completed_score = None; + + while let Some(event) = receiver.blocking_recv() { + match event { + SolverEvent::Completed { solution, .. } => { + completed_score = solution.score; + if let Some(score) = solution.score { + if score.hard_score() != solverforge::HardSoftDecimalScore::ZERO { + let mut mismatches = BTreeMap::<(String, u32, String), usize>::new(); + for shift in &solution.shifts { + let Some(employee_idx) = shift.employee_idx else { + continue; + }; + let employee = &solution.employees[employee_idx]; + if !employee.skills.contains(&shift.required_skill) { + *mismatches + .entry(( + shift.location.clone(), + shift.start.time().hour(), + shift.required_skill.clone(), + )) + .or_default() += 1; + } + } + eprintln!("large demo skill mismatches: {mismatches:?}"); + + let constraints = crate::constraints::create_constraints(); + let analyses = constraints.evaluate_detailed(&solution); + let hard_breakdown: Vec<_> = analyses + .into_iter() + .filter(|analysis| { + analysis.score.hard_score() + != solverforge::HardSoftDecimalScore::ZERO + }) + .map(|analysis| { + format!("{}={}", analysis.constraint_ref.name, analysis.score) + }) + .collect(); + eprintln!("large demo hard breakdown: {}", hard_breakdown.join(", ")); + } + } + break; + } + SolverEvent::Failed { error, .. } => { + panic!("large demo solve failed unexpectedly: {error}"); + } + _ => {} + } + } + + let score = completed_score.expect("expected a completed score"); + assert_eq!(score.hard_score(), solverforge::HardSoftDecimalScore::ZERO); + + MANAGER.delete(job_id).expect("delete completed job"); +} diff --git a/src/data/data_seed/tests.rs b/src/data/data_seed/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f2a795b46cbf50bf47eb05d6ac2e38089ecd283d --- /dev/null +++ b/src/data/data_seed/tests.rs @@ -0,0 +1,284 @@ +use super::availability::add_extra_unavailability; +use super::cohorts::assign_primary_off_days; +use super::coverage::public_candidate_counts; +use super::demand::DEMAND_RULES; +use super::employees::{build_employee_blueprints, instantiate_employees}; +use super::preferences::{ + add_preferences, eligible_employees_by_shift, shift_soft_preference_score, +}; +use super::shifts::{build_public_shifts, prepare_shifts}; +use super::time_utils::find_next_monday; +use super::vocabulary::*; +use super::witness::build_hidden_witness; +use super::*; +use chrono::{Datelike, Duration, NaiveDate, Timelike}; +use rand::rngs::StdRng; +use rand::SeedableRng; +use solverforge::ConstraintSet; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::domain::Plan; + +// These tests lock down the generator contract: workforce shape, shift counts, +// feasibility margins, and the intended soft-score signal surface. + +fn schedule() -> Plan { + generate(DemoData::Large) +} + +#[test] +fn test_generate_large() { + let schedule = schedule(); + + assert_eq!(schedule.employees.len(), 50); + assert_eq!(schedule.shifts.len(), 688); +} + +#[test] +fn test_exact_workforce_composition() { + let schedule = schedule(); + let employees = &schedule.employees; + + let doctors = employees + .iter() + .filter(|employee| employee.skills.contains(DOCTOR)) + .count(); + let nurses = employees + .iter() + .filter(|employee| employee.skills.contains(NURSE)) + .count(); + let cardiology = employees + .iter() + .filter(|employee| employee.skills.contains(CARDIOLOGY)) + .count(); + let anaesthetics = employees + .iter() + .filter(|employee| employee.skills.contains(ANAESTHETICS)) + .count(); + let radiology_day = employees + .iter() + .filter(|employee| employee.skills.contains(RADIOLOGY_DAY)) + .count(); + let radiology_nurse = employees + .iter() + .filter(|employee| employee.skills.contains(RADIOLOGY_NURSE)) + .count(); + let radiology_call = employees + .iter() + .filter(|employee| employee.skills.contains(RADIOLOGY_CALL)) + .count(); + let ambulatory_doctors = employees + .iter() + .filter(|employee| employee.skills.contains(AMBULATORY_DOCTOR)) + .count(); + let ambulatory_nurses = employees + .iter() + .filter(|employee| employee.skills.contains(AMBULATORY_NURSE)) + .count(); + let critical_doctors = employees + .iter() + .filter(|employee| employee.skills.contains(CRITICAL_DOCTOR)) + .count(); + let critical_nurses = employees + .iter() + .filter(|employee| employee.skills.contains(CRITICAL_NURSE)) + .count(); + let outpatient_doctors = employees + .iter() + .filter(|employee| employee.skills.contains(OUTPATIENT_DOCTOR)) + .count(); + let outpatient_nurses = employees + .iter() + .filter(|employee| employee.skills.contains(OUTPATIENT_NURSE)) + .count(); + + assert_eq!(doctors, 22); + assert_eq!(nurses, 28); + assert_eq!(cardiology, 4); + assert_eq!(anaesthetics, 6); + assert_eq!(radiology_day, 6); + assert_eq!(radiology_nurse, 6); + assert_eq!(radiology_call, 4); + assert_eq!(ambulatory_doctors, 4); + assert_eq!(ambulatory_nurses, 6); + assert_eq!(critical_doctors, 6); + assert_eq!(critical_nurses, 8); + assert_eq!(outpatient_doctors, 7); + assert_eq!(outpatient_nurses, 9); +} + +#[test] +fn test_exact_shift_template_counts() { + let schedule = schedule(); + let mut actual = BTreeMap::<(String, u32, String), usize>::new(); + for shift in &schedule.shifts { + *actual + .entry(( + shift.location.clone(), + shift.start.time().hour(), + shift.required_skill.clone(), + )) + .or_default() += 1; + } + + let mut expected = BTreeMap::<(String, u32, String), usize>::new(); + let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()); + for day in 0..DAYS_IN_SCHEDULE { + let date = start_date + Duration::days(day); + for rule in DEMAND_RULES { + *expected + .entry(( + rule.location.to_string(), + rule.start_hour, + rule.required_skill.to_string(), + )) + .or_default() += rule.count_for_date(date.weekday()); + } + } + + assert_eq!(actual, expected); +} + +#[test] +fn test_preferences_are_disjoint_from_unavailability() { + let schedule = schedule(); + for employee in &schedule.employees { + assert!(employee + .desired_dates + .iter() + .all(|date| !employee.unavailable_dates.contains(date))); + assert!(employee + .undesired_dates + .iter() + .all(|date| !employee.unavailable_dates.contains(date))); + assert!((4..=MAX_DESIRED_DATES).contains(&employee.desired_dates.len())); + assert!((4..=MAX_UNDESIRED_DATES).contains(&employee.undesired_dates.len())); + assert!(employee + .desired_dates + .iter() + .all(|date| !employee.undesired_dates.contains(date))); + } +} + +#[test] +fn test_preference_surface_has_one_move_signal() { + let schedule = schedule(); + let witness = build_hidden_witness(&schedule.employees, &schedule.shifts); + let candidate_lists = eligible_employees_by_shift(&schedule.employees, &schedule.shifts); + let signal_shifts = schedule + .shifts + .iter() + .enumerate() + .filter(|(shift_index, shift)| { + let holder = witness.assignments[*shift_index]; + let holder_score = shift_soft_preference_score(&schedule.employees[holder], shift); + candidate_lists[*shift_index] + .iter() + .copied() + .filter(|&candidate| candidate != holder) + .any(|candidate| { + let candidate_score = + shift_soft_preference_score(&schedule.employees[candidate], shift); + candidate_score - holder_score >= 2 + }) + }) + .count(); + + assert!( + signal_shifts > 0, + "there should still be some one-move soft improvements" + ); +} + +#[test] +fn test_public_candidate_redundancy() { + let schedule = schedule(); + let counts = public_candidate_counts(&schedule.employees, &schedule.shifts); + assert!(counts.iter().all(|&count| count >= 2)); + assert!(counts.iter().filter(|&&count| count >= 3).count() * 4 >= counts.len()); +} + +#[test] +fn test_candidate_width_is_not_generic_role_wide() { + let schedule = schedule(); + let mut counts = public_candidate_counts(&schedule.employees, &schedule.shifts); + counts.sort_unstable(); + let median = counts[counts.len() / 2]; + let p90 = counts[counts.len() * 9 / 10]; + + assert!(median <= 7, "median candidate count should stay narrow"); + assert!( + p90 <= 9, + "90th percentile candidate count should stay bounded" + ); +} + +#[test] +fn test_hidden_witness_is_hard_feasible() { + let mut rng = StdRng::seed_from_u64(0); + let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()); + let mut blueprints = build_employee_blueprints(&mut rng); + assign_primary_off_days(&mut blueprints); + let mut employees = instantiate_employees(&blueprints, start_date); + let mut shifts = build_public_shifts(start_date); + prepare_shifts(&mut shifts); + + let witness = build_hidden_witness(&employees, &shifts); + add_extra_unavailability(&mut employees, &shifts, &witness.employee_touched_dates); + add_preferences(&mut employees, start_date, &blueprints, &shifts, &witness); + + for (shift, employee_idx) in shifts.iter_mut().zip(witness.assignments.iter()) { + shift.employee_idx = Some(*employee_idx); + } + + let witness_schedule = Plan::new(employees, shifts); + let score = crate::constraints::create_constraints().evaluate_all(&witness_schedule); + assert_eq!(score.hard_score(), solverforge::HardSoftDecimalScore::ZERO); +} + +#[test] +fn test_employees_have_skills() { + let schedule = schedule(); + + for employee in &schedule.employees { + assert!( + !employee.skills.is_empty(), + "Employee {} has no skills", + employee.name + ); + } +} + +#[test] +fn test_demo_data_from_str() { + assert_eq!("LARGE".parse::(), Ok(DemoData::Large)); + assert_eq!("large".parse::(), Ok(DemoData::Large)); + assert!("invalid".parse::().is_err()); +} + +#[test] +fn test_medical_domain() { + let schedule = schedule(); + + let all_skills: BTreeSet<_> = schedule + .employees + .iter() + .flat_map(|employee| employee.skills.iter()) + .map(|skill| skill.as_str()) + .collect(); + + assert!(all_skills.contains(DOCTOR) || all_skills.contains(NURSE)); + let locations: BTreeSet<_> = schedule + .shifts + .iter() + .map(|shift| shift.location.as_str()) + .collect(); + assert!(locations.contains("Ambulatory care") || locations.contains("Critical care")); +} + +#[test] +fn test_empty_schedule_has_score() { + let schedule = crate::domain::Plan::new(vec![], vec![]); + let score = crate::constraints::create_constraints().evaluate_all(&schedule); + assert_eq!(score.to_string(), "0hard/0soft"); +} diff --git a/src/data/data_seed/time_utils.rs b/src/data/data_seed/time_utils.rs new file mode 100644 index 0000000000000000000000000000000000000000..10c233153e13a1b120314369a818357d8dae2550 --- /dev/null +++ b/src/data/data_seed/time_utils.rs @@ -0,0 +1,119 @@ +use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Weekday}; +use rand::prelude::*; +use rand::rngs::StdRng; +use std::collections::BTreeSet; + +use crate::domain::Shift; + +use super::vocabulary::{DAYS_IN_SCHEDULE, FIRST_NAMES, LAST_NAMES}; + +/// Returns the sorted set of dates touched by the published shifts. +pub(super) fn horizon_dates(shifts: &[Shift]) -> Vec { + let mut dates = BTreeSet::new(); + for shift in shifts { + for &date in &shift.touched_dates { + dates.insert(date); + } + } + dates.into_iter().collect() +} + +/// Buckets every schedule date by weekday for preference shaping. +pub(super) fn weekday_dates(start_date: NaiveDate) -> [Vec; 7] { + let mut dates = std::array::from_fn(|_| Vec::new()); + for day in 0..DAYS_IN_SCHEDULE { + let date = start_date + Duration::days(day); + dates[date.weekday().num_days_from_monday() as usize].push(date); + } + dates +} + +/// Finds a weekday that still exposes four available dates after unavailability. +pub(super) fn choose_weekday_with_four_available_dates( + unavailable_dates: &BTreeSet, + primary_off: usize, + candidates: impl IntoIterator, +) -> usize { + candidates + .into_iter() + .filter(|&weekday| weekday != primary_off) + .find(|&weekday| { + weekday_dates(find_next_monday( + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + ))[weekday] + .iter() + .filter(|date| !unavailable_dates.contains(date)) + .count() + == 4 + }) + .expect("weekday with four available dates should exist") +} + +/// Expands a time span into the calendar dates it touches. +pub(super) fn dates_touched_by_span(start: NaiveDateTime, end: NaiveDateTime) -> Vec { + let mut touched_dates = Vec::new(); + let mut date = start.date(); + + while date <= end.date() { + if overlap_minutes_for_day(start, end, date) > 0 { + touched_dates.push(date); + } + + let Some(next_date) = date.succ_opt() else { + break; + }; + date = next_date; + } + + touched_dates +} + +/// Measures how many minutes of the span overlap one specific date. +fn overlap_minutes_for_day(start: NaiveDateTime, end: NaiveDateTime, date: NaiveDate) -> i64 { + let day_start = date.and_hms_opt(0, 0, 0).unwrap(); + let day_end = date + .succ_opt() + .unwrap_or(date) + .and_hms_opt(0, 0, 0) + .unwrap(); + + let overlap_start = start.max(day_start); + let overlap_end = end.min(day_end); + + if overlap_start < overlap_end { + (overlap_end - overlap_start).num_minutes() + } else { + 0 + } +} + +/// Creates a deterministic shuffled name list for the workforce generator. +pub(super) fn generate_name_permutations(rng: &mut StdRng) -> Vec { + let mut names = Vec::with_capacity(FIRST_NAMES.len() * LAST_NAMES.len()); + for first in FIRST_NAMES { + for last in LAST_NAMES { + names.push(format!("{first} {last}")); + } + } + names.shuffle(rng); + names +} + +/// Small helper so shift templates can read as `time(14, 0)`. +pub(super) fn time(hour: u32, minute: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, minute, 0).unwrap() +} + +/// Anchors the benchmark to a Monday so weekday-based rules stay stable. +pub(super) fn find_next_monday(date: NaiveDate) -> NaiveDate { + let days_until_monday = match date.weekday() { + Weekday::Mon => 0, + Weekday::Tue => 6, + Weekday::Wed => 5, + Weekday::Thu => 4, + Weekday::Fri => 3, + Weekday::Sat => 2, + Weekday::Sun => 1, + }; + date + Duration::days(days_until_monday) +} diff --git a/src/data/data_seed/validation.rs b/src/data/data_seed/validation.rs new file mode 100644 index 0000000000000000000000000000000000000000..aa11cd190cd78f6e4b75dd20eea5c196ad52306b --- /dev/null +++ b/src/data/data_seed/validation.rs @@ -0,0 +1,32 @@ +use chrono::Timelike; + +use crate::domain::{Employee, Shift}; + +use super::coverage::{candidate_redundancy_is_valid, public_candidate_counts}; + +/// Verifies that the exact public dataset we will ship still matches generator goals. +pub(super) fn validate_public_dataset(employees: &[Employee], shifts: &[Shift]) { + let counts = public_candidate_counts(employees, shifts); + let min_count = counts.iter().copied().min().unwrap_or(0); + let three_plus = counts.iter().filter(|&&count| count >= 3).count(); + let weakest: Vec = shifts + .iter() + .zip(counts.iter()) + .filter(|(_, &count)| count == min_count) + .take(5) + .map(|(shift, &count)| { + format!( + "{} {} {} -> {}", + shift.location, + shift.start.time().hour(), + shift.required_skill, + count + ) + }) + .collect(); + assert!( + candidate_redundancy_is_valid(employees, shifts), + "public dataset should maintain candidate redundancy; min_count={min_count}, three_plus={three_plus}/{} weakest={weakest:?}", + counts.len() + ); +} diff --git a/src/data/data_seed/vocabulary.rs b/src/data/data_seed/vocabulary.rs new file mode 100644 index 0000000000000000000000000000000000000000..7d41e78671e2cc720be130fba7d1ef5d76755fbf --- /dev/null +++ b/src/data/data_seed/vocabulary.rs @@ -0,0 +1,41 @@ +//! Generator constants and shared medical vocabulary. + +pub(super) const DAYS_IN_SCHEDULE: i64 = 28; +pub(super) const EMPLOYEE_COUNT: usize = 50; +pub(super) const EXTRA_UNAVAILABLE_COUNT: usize = 6; +pub(super) const PRIMARY_OFF_COHORT_SIZES: [usize; 7] = [7, 7, 7, 7, 7, 7, 8]; +pub(super) const TARGET_DESIRED_DATES: usize = 4; +pub(super) const TARGET_UNDESIRED_DATES: usize = 4; +pub(super) const MAX_DESIRED_DATES: usize = 5; +pub(super) const MAX_UNDESIRED_DATES: usize = 5; +// `None` disables the witness-relative exchange phase entirely. Using an +// explicit option keeps the policy honest; `0` would be a sentinel value with +// different semantics disguised as a normal numeric limit. +pub(super) const EXCHANGE_MARK_LIMIT_PER_EMPLOYEE: Option = None; + +pub(super) const DOCTOR: &str = "Doctor"; +pub(super) const NURSE: &str = "Nurse"; +pub(super) const AMBULATORY_DOCTOR: &str = "Ambulatory doctor"; +pub(super) const AMBULATORY_NURSE: &str = "Ambulatory nurse"; +pub(super) const NEUROLOGY_DOCTOR: &str = "Neurology doctor"; +pub(super) const NEUROLOGY_NURSE: &str = "Neurology nurse"; +pub(super) const CRITICAL_DOCTOR: &str = "Critical care doctor"; +pub(super) const CRITICAL_NURSE: &str = "Critical care nurse"; +pub(super) const PEDIATRIC_DOCTOR: &str = "Pediatric doctor"; +pub(super) const PEDIATRIC_NURSE: &str = "Pediatric nurse"; +pub(super) const SURGERY_DOCTOR: &str = "Surgery doctor"; +pub(super) const SURGERY_NURSE: &str = "Surgery nurse"; +pub(super) const OUTPATIENT_DOCTOR: &str = "Outpatient doctor"; +pub(super) const OUTPATIENT_NURSE: &str = "Outpatient nurse"; +pub(super) const RADIOLOGY_DAY: &str = "Radiology day"; +pub(super) const RADIOLOGY_NURSE: &str = "Radiology nurse"; +pub(super) const RADIOLOGY_CALL: &str = "Radiology call"; +pub(super) const CARDIOLOGY: &str = "Cardiology"; +pub(super) const ANAESTHETICS: &str = "Anaesthetics"; + +pub(super) const FIRST_NAMES: &[&str] = &[ + "Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay", +]; +pub(super) const LAST_NAMES: &[&str] = &[ + "Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt", +]; diff --git a/src/data/data_seed/witness.rs b/src/data/data_seed/witness.rs new file mode 100644 index 0000000000000000000000000000000000000000..e6dcefa779b65ec08d0177534bcd6f98140cb544 --- /dev/null +++ b/src/data/data_seed/witness.rs @@ -0,0 +1,235 @@ +use chrono::{NaiveDate, Timelike}; +use std::collections::BTreeSet; + +use crate::domain::{Employee, Shift}; + +use super::coverage::employee_can_cover_shift_without_schedule; +use super::skills::{is_doctor_family_skill, is_nurse_family_skill, is_specialty_skill}; +use super::vocabulary::*; + +/// Running load summary while building the hidden feasible witness roster. +#[derive(Default)] +struct WitnessLoad { + shift_indices: Vec, + touched_date_load: usize, + night_count: usize, + specialty_count: usize, +} + +/// Internal, never-exposed feasible assignment used to shape the public dataset. +pub(super) struct WitnessRoster { + pub(super) assignments: Vec, + pub(super) employee_touched_dates: Vec>, +} + +/// Builds a guaranteed hard-feasible hidden roster for the generated shifts. +pub(super) fn build_hidden_witness(employees: &[Employee], shifts: &[Shift]) -> WitnessRoster { + let mut assignments = vec![usize::MAX; shifts.len()]; + let mut loads: Vec = (0..employees.len()) + .map(|_| WitnessLoad::default()) + .collect(); + let mut employee_touched_dates: Vec> = + (0..employees.len()).map(|_| BTreeSet::new()).collect(); + let eligible_employees_by_shift: Vec> = shifts + .iter() + .map(|shift| { + employees + .iter() + .enumerate() + .filter(|(_, employee)| employee_can_cover_shift_without_schedule(employee, shift)) + .map(|(employee_index, _)| employee_index) + .collect() + }) + .collect(); + let mut remaining: Vec = (0..shifts.len()).collect(); + + while !remaining.is_empty() { + let (remaining_index, shift_index, feasible_employees) = remaining + .iter() + .enumerate() + .map(|(remaining_index, &shift_index)| { + let shift = &shifts[shift_index]; + let feasible_employees: Vec = eligible_employees_by_shift[shift_index] + .iter() + .copied() + .filter(|&employee_index| { + employee_can_cover_shift_in_witness( + employee_index, + shift, + employees, + shifts, + &loads, + ) + }) + .collect(); + (remaining_index, shift_index, feasible_employees) + }) + .min_by_key(|(_, shift_index, feasible_employees)| { + let shift = &shifts[*shift_index]; + ( + feasible_employees.len(), + eligible_employees_by_shift[*shift_index].len(), + shift_priority_rank(shift), + shift.start, + *shift_index, + ) + }) + .expect("remaining shifts should produce a selection"); + + let shift = &shifts[shift_index]; + + assert!( + !feasible_employees.is_empty(), + "witness roster should be feasible for shift {} {} {}", + shift.id, + shift.location, + shift.required_skill + ); + + let employee_index = feasible_employees + .into_iter() + .min_by_key(|&candidate| witness_candidate_key(candidate, shift, employees, &loads)) + .expect("feasible employee should exist"); + + assignments[shift_index] = employee_index; + let load = &mut loads[employee_index]; + load.shift_indices.push(shift_index); + load.touched_date_load += shift.touched_dates.len(); + if shift.start.time().hour() == 22 { + load.night_count += 1; + } + if is_specialty_skill(&shift.required_skill) { + load.specialty_count += 1; + } + employee_touched_dates[employee_index].extend(shift.touched_dates.iter().copied()); + remaining.swap_remove(remaining_index); + } + + WitnessRoster { + assignments, + employee_touched_dates, + } +} + +/// Orders shifts from hardest-to-place to easiest-to-place for witness construction. +pub(super) fn shift_priority_rank(shift: &Shift) -> usize { + match (shift.required_skill.as_str(), shift.start.time().hour()) { + (CARDIOLOGY, _) => 0, + (ANAESTHETICS, _) => 1, + (RADIOLOGY_CALL, _) => 2, + (RADIOLOGY_DAY, _) => 3, + (skill, 22) if is_doctor_family_skill(skill) => 4, + (skill, _) if is_doctor_family_skill(skill) => 5, + (skill, 22) if is_nurse_family_skill(skill) => 6, + (skill, _) if is_nurse_family_skill(skill) => 7, + _ => 8, + } +} + +/// Lower keys mean "better witness assignee for this shift". +fn witness_candidate_key( + employee_index: usize, + shift: &Shift, + employees: &[Employee], + loads: &[WitnessLoad], +) -> (usize, usize, usize, usize, usize, usize, usize) { + let load = &loads[employee_index]; + let employee = &employees[employee_index]; + ( + witness_role_mismatch_penalty(employee, shift), + witness_specialty_overhang(employee, shift), + load.touched_date_load, + load.night_count, + load.specialty_count, + witness_is_floater(employee), + employee.index, + ) +} + +/// Penalizes choosing a doctor-family mismatch or nurse-family mismatch. +fn witness_role_mismatch_penalty(employee: &Employee, shift: &Shift) -> usize { + let has_doctor = employee.skills.contains(DOCTOR); + let has_nurse = employee.skills.contains(NURSE); + if is_doctor_family_skill(&shift.required_skill) { + usize::from(!has_doctor) * 10 + } else if is_nurse_family_skill(&shift.required_skill) { + usize::from(!has_nurse) * 10 + } else { + 0 + } +} + +/// Prefers not to spend scarce specialties on non-matching work when avoidable. +fn witness_specialty_overhang(employee: &Employee, shift: &Shift) -> usize { + [CARDIOLOGY, ANAESTHETICS, RADIOLOGY_CALL, RADIOLOGY_DAY] + .into_iter() + .filter(|skill| employee.skills.contains(*skill) && *skill != shift.required_skill) + .count() +} + +/// Detects the rare "super-floater" profile so it is used carefully. +fn witness_is_floater(employee: &Employee) -> usize { + usize::from( + employee.skills.contains(CARDIOLOGY) + && employee.skills.contains(ANAESTHETICS) + && employee.skills.contains(RADIOLOGY_CALL), + ) +} + +/// Checks whether one employee can take a shift inside the hidden witness roster. +fn employee_can_cover_shift_in_witness( + employee_index: usize, + shift: &Shift, + employees: &[Employee], + shifts: &[Shift], + loads: &[WitnessLoad], +) -> bool { + let employee = &employees[employee_index]; + if !employee.skills.contains(&shift.required_skill) { + return false; + } + if shift + .touched_dates + .iter() + .any(|date| employee.unavailable_dates.contains(date)) + { + return false; + } + + for &other_shift_index in &loads[employee_index].shift_indices { + let other = &shifts[other_shift_index]; + if shares_touched_date(shift, other) + || shifts_overlap(shift, other) + || violates_rest(shift, other) + { + return false; + } + } + + true +} + +/// Returns whether two shifts touch any common calendar date. +fn shares_touched_date(left: &Shift, right: &Shift) -> bool { + left.touched_dates + .iter() + .any(|date| right.touched_dates.contains(date)) +} + +/// Returns whether two shift time windows overlap in absolute time. +fn shifts_overlap(left: &Shift, right: &Shift) -> bool { + left.start < right.end && right.start < left.end +} + +/// Returns whether two non-overlapping shifts still violate the 10-hour rest rule. +fn violates_rest(left: &Shift, right: &Shift) -> bool { + let (earlier, later) = if left.end <= right.start { + (left, right) + } else if right.end <= left.start { + (right, left) + } else { + return false; + }; + let gap_minutes = (later.start - earlier.end).num_minutes(); + (0..600).contains(&gap_minutes) +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..48160d3958684681a8e392a12f13fa3d68b998ba --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,9 @@ +//! Stable public entrypoint for demo data. +//! +//! Other modules should import from `crate::data` instead of reaching directly +//! into `data_seed/`. That keeps the app's public data surface small even though +//! the generator itself is split across many focused files. + +mod data_seed; + +pub use data_seed::{generate, list_demo_data, DemoData}; diff --git a/src/domain/care_hub.rs b/src/domain/care_hub.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf2f48cd057b13db91956c6bd6f1267457061380 --- /dev/null +++ b/src/domain/care_hub.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; + +/// Coarse service-line grouping used to make nearby search meaningful. +/// +/// The solver does not understand "hospital geography" by itself. We therefore +/// encode a lightweight domain signal that says which locations and employee +/// skill bundles are close to one another. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum CareHub { + Ambulatory, + Neurology, + CriticalCare, + PediatricCare, + Surgery, + Radiology, + Outpatient, + #[default] + Unknown, +} + +impl CareHub { + /// Maps a published shift location label to the hub used by nearby search. + pub fn from_location(location: &str) -> Self { + match location { + "Ambulatory care" => Self::Ambulatory, + "Neurology" => Self::Neurology, + "Critical care" => Self::CriticalCare, + "Pediatric care" => Self::PediatricCare, + "Surgery" => Self::Surgery, + "Radiology" => Self::Radiology, + "Outpatient" => Self::Outpatient, + _ => Self::Unknown, + } + } + + /// Maps a required skill to the hub that most naturally owns that work. + pub fn from_skill(skill: &str) -> Option { + match skill { + "Ambulatory doctor" | "Ambulatory nurse" => Some(Self::Ambulatory), + "Neurology doctor" | "Neurology nurse" | "Cardiology" => Some(Self::Neurology), + "Critical care doctor" | "Critical care nurse" => Some(Self::CriticalCare), + "Pediatric doctor" | "Pediatric nurse" => Some(Self::PediatricCare), + "Surgery doctor" | "Surgery nurse" | "Anaesthetics" => Some(Self::Surgery), + "Radiology day" | "Radiology nurse" | "Radiology call" => Some(Self::Radiology), + "Outpatient doctor" | "Outpatient nurse" => Some(Self::Outpatient), + _ => None, + } + } + + /// Guesses an employee's home hub from the service-line skills they carry. + /// + /// This is only a fallback for generated or decoded employees that did not + /// set `home_hub` explicitly. + pub fn infer_from_skills<'a>(skills: impl IntoIterator) -> Self { + let mut counts = [0usize; 7]; + for skill in skills { + match Self::from_skill(skill) { + Some(Self::Ambulatory) => counts[0] += 1, + Some(Self::Neurology) => counts[1] += 1, + Some(Self::CriticalCare) => counts[2] += 1, + Some(Self::PediatricCare) => counts[3] += 1, + Some(Self::Surgery) => counts[4] += 1, + Some(Self::Radiology) => counts[5] += 1, + Some(Self::Outpatient) => counts[6] += 1, + Some(Self::Unknown) | None => {} + } + } + + let Some((best_index, best_count)) = counts + .iter() + .copied() + .enumerate() + .max_by_key(|&(index, count)| (count, index)) + else { + return Self::Unknown; + }; + + if best_count == 0 { + Self::Unknown + } else { + match best_index { + 0 => Self::Ambulatory, + 1 => Self::Neurology, + 2 => Self::CriticalCare, + 3 => Self::PediatricCare, + 4 => Self::Surgery, + 5 => Self::Radiology, + 6 => Self::Outpatient, + _ => Self::Unknown, + } + } + } +} diff --git a/src/domain/employee.rs b/src/domain/employee.rs new file mode 100644 index 0000000000000000000000000000000000000000..ec27f17a60b8100148e408fc8eb2153d083c8e6b --- /dev/null +++ b/src/domain/employee.rs @@ -0,0 +1,114 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; +use std::collections::BTreeSet; + +use super::CareHub; + +/// Hospital staff member published as a SolverForge problem fact. +/// +/// A few fields are "authoritative transport state" (`*_dates`), while others +/// are precomputed runtime helpers (`index`, `*_days`). `finalize()` keeps those +/// two views in sync after generation or JSON decoding. +#[problem_fact] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Employee { + pub id: String, + #[serde(skip)] + pub index: usize, + pub name: String, + #[serde(default)] + pub home_hub: CareHub, + #[serde(default)] + pub skills: BTreeSet, + #[serde(default)] + pub unavailable_dates: BTreeSet, + #[serde(default)] + pub undesired_dates: BTreeSet, + #[serde(default)] + pub desired_dates: BTreeSet, + #[serde(skip)] + pub unavailable_days: Vec, + #[serde(skip)] + pub undesired_days: Vec, + #[serde(skip)] + pub desired_days: Vec, +} + +impl Employee { + /// Creates a beginner-friendly builder seed with stable defaults. + pub fn new(index: usize, name: impl Into) -> Self { + Self { + id: format!("employee-{index}"), + index, + name: name.into(), + home_hub: CareHub::Unknown, + skills: BTreeSet::new(), + unavailable_dates: BTreeSet::new(), + undesired_dates: BTreeSet::new(), + desired_dates: BTreeSet::new(), + unavailable_days: Vec::new(), + undesired_days: Vec::new(), + desired_days: Vec::new(), + } + } + + /// Overrides the transport-visible identifier. + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Sets the employee's home service line used by nearby search. + pub fn with_home_hub(mut self, home_hub: CareHub) -> Self { + self.home_hub = home_hub; + self + } + + /// Rebuilds the derived caches the solver reads frequently. + /// + /// The serialized `BTreeSet`s are the stable truth for transport. The + /// `Vec`s are just pre-expanded, iteration-friendly mirrors used by + /// constraints and heuristics. + pub fn finalize(&mut self) { + if self.home_hub == CareHub::Unknown { + self.home_hub = CareHub::infer_from_skills(self.skills.iter().map(String::as_str)); + } + self.unavailable_days = self.unavailable_dates.iter().copied().collect(); + self.undesired_days = self.undesired_dates.iter().copied().collect(); + self.desired_days = self.desired_dates.iter().copied().collect(); + } + + /// Adds one service-line skill to the employee. + pub fn with_skill(mut self, skill: impl Into) -> Self { + self.skills.insert(skill.into()); + self + } + + /// Adds several skills in one builder step. + pub fn with_skills(mut self, skills: impl IntoIterator>) -> Self { + for skill in skills { + self.skills.insert(skill.into()); + } + self + } + + /// Marks a day as completely unavailable. + pub fn with_unavailable_date(mut self, date: NaiveDate) -> Self { + self.unavailable_dates.insert(date); + self + } + + /// Marks a day the employee would prefer to avoid. + pub fn with_undesired_date(mut self, date: NaiveDate) -> Self { + self.undesired_dates.insert(date); + self + } + + /// Marks a day the employee would actively like to work. + pub fn with_desired_date(mut self, date: NaiveDate) -> Self { + self.desired_dates.insert(date); + self + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..8a65f072f363319b2890ea540c22f2cbea1faa2c --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,21 @@ +//! Planning-model manifest and domain-layer exports. +//! +//! `planning_model!` is the current SolverForge manifest for the domain. It +//! lists the file-backed model modules, exports the public names used by the +//! rest of the app, and lets the runtime attach model-owned scalar hooks. + +solverforge::planning_model! { + root = "src/domain"; + + // @solverforge:begin domain-exports + mod care_hub; + mod employee; + mod plan; + + pub use care_hub::CareHub; + pub use employee::Employee; + pub use plan::Plan; + pub use plan::PlanConstraintStreams; + pub use plan::Shift; + // @solverforge:end domain-exports +} diff --git a/src/domain/plan.rs b/src/domain/plan.rs new file mode 100644 index 0000000000000000000000000000000000000000..2db21902bac6931d5fbfa3d54f34d29258cbb1e8 --- /dev/null +++ b/src/domain/plan.rs @@ -0,0 +1,309 @@ +//! Domain model for the hospital employee scheduling problem. + +use chrono::{NaiveDate, NaiveDateTime, Timelike}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use solverforge::prelude::*; + +use super::{CareHub, Employee}; + +/// Work item that the solver must assign to exactly one employee or leave open. +/// +/// In this example a shift is the only planning entity, which keeps the +/// beginner mental model simple: SolverForge is choosing `employee_idx` values +/// for each `Shift`. +#[planning_entity] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Shift { + #[planning_id] + pub id: String, + #[serde(skip)] + pub index: usize, + pub start: NaiveDateTime, + pub end: NaiveDateTime, + pub location: String, + #[serde(default)] + pub care_hub: CareHub, + pub required_skill: String, + #[serde(skip)] + pub touched_dates: Vec, + // SolverForge mutates this scalar slot. The value is an index into + // `Plan.employees`; `Employee.id` remains transport identity for API/UI use. + #[planning_variable( + value_range_provider = "employees", + allows_unassigned = true, + candidate_values = "shift_employee_candidates", + nearby_value_candidates = "shift_nearby_employee_candidates", + nearby_entity_candidates = "shift_nearby_shift_candidates", + nearby_value_distance_meter = "shift_to_employee_nearby_distance", + nearby_entity_distance_meter = "shift_to_shift_nearby_distance" + )] + pub employee_idx: Option, +} + +impl Shift { + /// Creates a new unassigned shift and derives its first-pass care hub. + pub fn new( + id: impl Into, + start: NaiveDateTime, + end: NaiveDateTime, + location: impl Into, + required_skill: impl Into, + ) -> Self { + let location = location.into(); + Self { + id: id.into(), + index: 0, + start, + end, + care_hub: CareHub::from_location(&location), + location, + required_skill: required_skill.into(), + touched_dates: Vec::new(), + employee_idx: None, + } + } + + /// Returns every calendar day touched by the shift, including overnight end days. + pub fn touched_dates(&self) -> &[NaiveDate] { + self.touched_dates.as_slice() + } + + /// Convenience helper used by tests and data exploration. + pub fn duration_hours(&self) -> f64 { + (self.end - self.start).num_minutes() as f64 / 60.0 + } +} + +/// Full planning solution published to the solver runtime and the HTTP API. +#[planning_solution( + constraints = "crate::constraints::create_constraints", + solver_toml = "../../solver.toml" +)] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Plan { + #[problem_fact_collection] + pub employees: Vec, + #[planning_entity_collection] + pub shifts: Vec, + #[planning_score] + pub score: Option, + #[serde(skip)] + employee_indices: Vec, + #[serde(skip)] + shift_indices: Vec, +} + +impl Plan { + /// Builds a plan and immediately restores all derived runtime helpers. + pub fn new(employees: Vec, shifts: Vec) -> Self { + let mut schedule = Self { + employees, + shifts, + score: None, + employee_indices: Vec::new(), + shift_indices: Vec::new(), + }; + schedule.rebuild_derived_fields(); + schedule + } + + /// Recomputes indexes, inferred hubs, touched dates, and range-safe assignments. + /// + /// This runs after generation and after transport decoding so the domain + /// model always reaches the solver in a normalized state. + pub fn rebuild_derived_fields(&mut self) { + for (index, employee) in self.employees.iter_mut().enumerate() { + employee.index = index; + employee.finalize(); + } + + for (index, shift) in self.shifts.iter_mut().enumerate() { + shift.index = index; + if shift.care_hub == CareHub::Unknown { + shift.care_hub = CareHub::from_location(&shift.location); + } + shift.touched_dates = dates_touched_by_span(shift.start, shift.end); + shift.employee_idx = shift + .employee_idx + .filter(|employee_idx| *employee_idx < self.employees.len()); + } + + self.employee_indices = (0..self.employees.len()).collect(); + self.shift_indices = (0..self.shifts.len()).collect(); + } + + /// Converts the domain model into a flat JSON-object field map for transport DTOs. + pub fn to_transport_fields(&self) -> Map { + match serde_json::to_value(self).expect("failed to serialize employee schedule") { + Value::Object(fields) => fields, + _ => Map::new(), + } + } + + /// Rebuilds a domain plan from the transport field map used by `PlanDto`. + pub fn from_transport_fields(fields: Map) -> Result { + let mut schedule: Self = serde_json::from_value(Value::Object(fields))?; + schedule.rebuild_derived_fields(); + Ok(schedule) + } + + /// Safe index lookup used by nearby meters and constraint helpers. + #[inline] + pub fn get_employee(&self, idx: usize) -> Option<&Employee> { + self.employees.get(idx) + } + + /// Convenience accessor used by tests and diagnostics. + #[inline] + pub fn employee_count(&self) -> usize { + self.employees.len() + } +} + +// Scalar candidate hooks return borrowed index slices so move generation can +// stay allocation-free while the detailed distance meters rank the choices. +pub(super) fn shift_employee_candidates( + solution: &Plan, + _entity_index: usize, + _variable_index: usize, +) -> &[usize] { + solution.employee_indices.as_slice() +} + +pub(super) fn shift_nearby_employee_candidates( + solution: &Plan, + entity_index: usize, + variable_index: usize, +) -> &[usize] { + shift_employee_candidates(solution, entity_index, variable_index) +} + +pub(super) fn shift_nearby_shift_candidates( + solution: &Plan, + _entity_index: usize, + _variable_index: usize, +) -> &[usize] { + solution.shift_indices.as_slice() +} + +// This nearby meter is deliberately cheap. It is not a feasibility oracle; it +// just nudges the selector toward promising employees before the real +// constraints do the exact scoring work. +pub(super) fn shift_to_employee_nearby_distance( + solution: &Plan, + shift: &Shift, + employee_index: usize, +) -> f64 { + let Some(employee) = solution.get_employee(employee_index) else { + return f64::INFINITY; + }; + + // Nearby meters run during move generation, so keep this intentionally + // cheap and mostly static. Hard feasibility is evaluated by constraints. + let mut distance = 10.0 * care_hub_distance(shift.care_hub, employee.home_hub); + + if !employee.skills.contains(&shift.required_skill) { + distance += 10_000.0; + } else if CareHub::from_skill(&shift.required_skill) != Some(employee.home_hub) { + distance += 12.0; + } + + if shift + .touched_dates() + .iter() + .any(|date| employee.unavailable_dates.contains(date)) + { + distance += 2_000.0; + } + + distance +} + +// Shift-to-shift proximity helps nearby swap selectors stay within roughly +// compatible service lines and time bands. +pub(super) fn shift_to_shift_nearby_distance(_solution: &Plan, left: &Shift, right: &Shift) -> f64 { + 10.0 * care_hub_distance(left.care_hub, right.care_hub) + + start_band_distance(left.start.time().hour(), right.start.time().hour()) +} + +/// Places care hubs on a tiny hand-authored grid so Manhattan distance is easy to explain. +fn care_hub_distance(left: CareHub, right: CareHub) -> f64 { + let (lx, ly) = care_hub_position(left); + let (rx, ry) = care_hub_position(right); + ((lx - rx).abs() + (ly - ry).abs()) as f64 +} + +/// Provides the synthetic coordinates used by `care_hub_distance`. +fn care_hub_position(hub: CareHub) -> (i32, i32) { + match hub { + CareHub::Ambulatory => (0, 0), + CareHub::Outpatient => (1, 0), + CareHub::PediatricCare => (0, 1), + CareHub::Neurology => (1, 1), + CareHub::CriticalCare => (2, 1), + CareHub::Surgery => (2, 2), + CareHub::Radiology => (3, 2), + CareHub::Unknown => (4, 4), + } +} + +/// Groups start times into broad bands so swaps prefer similar shift shapes. +fn start_band_distance(left_hour: u32, right_hour: u32) -> f64 { + let left_band = start_band_index(left_hour); + let right_band = start_band_index(right_hour); + (left_band.abs_diff(right_band).min(2)) as f64 +} + +/// Maps a wall-clock hour to the coarse start-time band used above. +fn start_band_index(hour: u32) -> u32 { + match hour { + 0..=7 => 0, + 8..=12 => 1, + 13..=17 => 2, + _ => 3, + } +} + +/// Expands a shift into the set of calendar dates it touches. +fn dates_touched_by_span(start: NaiveDateTime, end: NaiveDateTime) -> Vec { + let mut touched_dates = Vec::new(); + let mut date = start.date(); + + while date <= end.date() { + if overlap_minutes_for_day(start, end, date) > 0 { + touched_dates.push(date); + } + + let Some(next_date) = date.succ_opt() else { + break; + }; + date = next_date; + } + + touched_dates +} + +/// Measures how many minutes of a shift fall inside one specific calendar day. +fn overlap_minutes_for_day(start: NaiveDateTime, end: NaiveDateTime, date: NaiveDate) -> i64 { + let day_start = date.and_hms_opt(0, 0, 0).unwrap(); + let day_end = date + .succ_opt() + .unwrap_or(date) + .and_hms_opt(0, 0, 0) + .unwrap(); + + let overlap_start = start.max(day_start); + let overlap_end = end.min(day_end); + + if overlap_start < overlap_end { + (overlap_end - overlap_start).num_minutes() + } else { + 0 + } +} + +#[cfg(test)] +mod tests; diff --git a/src/domain/plan/tests.rs b/src/domain/plan/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..990d4f8803151dfa016e8d1c574e36179262e303 --- /dev/null +++ b/src/domain/plan/tests.rs @@ -0,0 +1,257 @@ +//! Tests for plan invariants, transport normalization, and embedded solver config. + +use super::*; +use crate::api::PlanDto; +use solverforge::{SolverEvent, SolverManager, SolverTerminalReason}; +use std::fs; +use std::sync::{Mutex, OnceLock}; + +#[test] +fn scalar_variable_uses_solution_level_employee_range_and_nearby_hooks() { + let descriptor = Plan::descriptor(); + let shift_descriptor = descriptor + .find_entity_descriptor("Shift") + .expect("Shift descriptor should exist"); + let variable = shift_descriptor + .find_variable("employee_idx") + .expect("employee_idx variable should exist"); + + assert_eq!(variable.value_range_provider, Some("employees")); + assert!(variable.candidate_values.is_some()); + assert!(variable.nearby_value_candidates.is_some()); + assert!(variable.nearby_entity_candidates.is_some()); + assert!(variable.nearby_value_distance_meter.is_some()); + assert!(variable.nearby_entity_distance_meter.is_some()); + assert!(variable.construction_entity_order_key.is_none()); + assert!(variable.construction_value_order_key.is_none()); +} + +#[test] +fn plan_dto_round_trip_rebuilds_domain_invariants() { + let schedule = Plan::new( + vec![ + Employee::new(0, "Alex") + .with_skill("Doctor") + .with_unavailable_date(NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()) + .with_undesired_date(NaiveDate::from_ymd_opt(2024, 1, 3).unwrap()) + .with_desired_date(NaiveDate::from_ymd_opt(2024, 1, 4).unwrap()), + Employee::new(1, "Taylor").with_skill("Nurse"), + ], + vec![{ + let mut shift = Shift::new( + "shift-1", + NaiveDate::from_ymd_opt(2024, 1, 2) + .unwrap() + .and_hms_opt(8, 0, 0) + .unwrap(), + NaiveDate::from_ymd_opt(2024, 1, 2) + .unwrap() + .and_hms_opt(16, 0, 0) + .unwrap(), + "ER", + "Nurse", + ); + shift.employee_idx = Some(1); + shift + }], + ); + + let dto = PlanDto::from_plan(&schedule); + let json = serde_json::to_value(&dto).unwrap(); + + assert_eq!(json["shifts"][0]["employeeIdx"], 1); + assert!(json["shifts"][0].get("assignedEmployeeId").is_none()); + assert!(json["shifts"][0].get("employeeRange").is_none()); + assert!(json["employees"][0].get("index").is_none()); + assert!(json["employees"][0].get("unavailableDays").is_none()); + assert_eq!(json["employees"][0]["unavailableDates"][0], "2024-01-02"); + assert_eq!(json["employees"][0]["undesiredDates"][0], "2024-01-03"); + assert_eq!(json["employees"][0]["desiredDates"][0], "2024-01-04"); + + let round_tripped = dto.to_domain().unwrap(); + assert_eq!(round_tripped.shifts[0].employee_idx, Some(1)); + assert_eq!(round_tripped.shifts[0].index, 0); + assert_eq!( + round_tripped.shifts[0].touched_dates, + vec![NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()] + ); + assert_eq!(round_tripped.employees[0].index, 0); + assert_eq!( + round_tripped.employees[0].unavailable_days, + vec![NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()] + ); +} + +#[test] +fn overnight_shift_tracks_each_touched_date_once() { + let schedule = Plan::new( + vec![Employee::new(0, "Alex").with_skill("Doctor")], + vec![Shift::new( + "night-1", + NaiveDate::from_ymd_opt(2024, 1, 1) + .unwrap() + .and_hms_opt(22, 0, 0) + .unwrap(), + NaiveDate::from_ymd_opt(2024, 1, 2) + .unwrap() + .and_hms_opt(6, 0, 0) + .unwrap(), + "ER", + "Doctor", + )], + ); + + assert_eq!( + schedule.shifts[0].touched_dates(), + &[ + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + NaiveDate::from_ymd_opt(2024, 1, 2).unwrap(), + ] + ); +} + +fn cwd_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +struct TempSolverConfigDir { + original_dir: std::path::PathBuf, + temp_dir: std::path::PathBuf, +} + +impl TempSolverConfigDir { + fn new(contents: &str) -> Self { + let original_dir = std::env::current_dir().expect("current directory should be readable"); + let temp_dir = std::env::temp_dir().join(format!( + "solverforge-hospital-config-test-{}", + std::process::id() + )); + + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).expect("temp solver directory should be created"); + fs::write(temp_dir.join("solver.toml"), contents) + .expect("temp solver.toml should be written"); + std::env::set_current_dir(&temp_dir).expect("current directory should switch to temp"); + + Self { + original_dir, + temp_dir, + } + } +} + +impl Drop for TempSolverConfigDir { + fn drop(&mut self) { + std::env::set_current_dir(&self.original_dir) + .expect("current directory should restore after test"); + let _ = fs::remove_dir_all(&self.temp_dir); + } +} + +#[test] +fn retained_runtime_uses_embedded_solver_toml_instead_of_cwd_defaults() { + static MANAGER: SolverManager = SolverManager::new(); + + let _cwd_lock = cwd_test_lock().lock().expect("cwd lock should be acquired"); + let _temp_solver_dir = TempSolverConfigDir::new("this = definitely not valid toml = ["); + + let (job_id, mut receiver) = MANAGER + .solve(Plan::new(Vec::new(), Vec::new())) + .expect("job should start"); + + let mut best_solution = None; + let mut cancel_requested = false; + let mut cancelled = false; + + while let Some(event) = receiver.blocking_recv() { + match event { + SolverEvent::BestSolution { solution, .. } => { + best_solution = Some(solution); + if !cancel_requested { + MANAGER + .cancel(job_id) + .expect("zero-work solve should cancel"); + cancel_requested = true; + } + } + SolverEvent::Cancelled { metadata } => { + assert_eq!( + metadata.terminal_reason, + Some(SolverTerminalReason::Cancelled) + ); + cancelled = true; + break; + } + SolverEvent::Progress { .. } + | SolverEvent::PauseRequested { .. } + | SolverEvent::Paused { .. } + | SolverEvent::Resumed { .. } => {} + SolverEvent::Completed { .. } => { + panic!("zero-work solve should remain active until cancellation") + } + SolverEvent::Failed { error, .. } => { + panic!("embedded solver config should run successfully: {error}") + } + } + } + + assert!(cancelled, "expected a cancelled event"); + assert_eq!( + best_solution + .expect("expected an initial best solution") + .score, + Some(HardSoftDecimalScore::ZERO) + ); + MANAGER.delete(job_id).expect("delete cancelled job"); +} + +#[test] +fn retained_runtime_assigns_single_shift_when_compatible_candidate_exists() { + static MANAGER: SolverManager = SolverManager::new(); + + let schedule = Plan::new( + vec![ + Employee::new(0, "Unavailable doctor") + .with_skill("Doctor") + .with_unavailable_date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()), + Employee::new(1, "Available doctor").with_skill("Doctor"), + ], + vec![Shift::new( + "shift-1", + NaiveDate::from_ymd_opt(2024, 1, 1) + .unwrap() + .and_hms_opt(8, 0, 0) + .unwrap(), + NaiveDate::from_ymd_opt(2024, 1, 1) + .unwrap() + .and_hms_opt(16, 0, 0) + .unwrap(), + "ER", + "Doctor", + )], + ); + + let (job_id, mut receiver) = MANAGER.solve(schedule).expect("job should start"); + let mut completed_solution = None; + + while let Some(event) = receiver.blocking_recv() { + match event { + SolverEvent::BestSolution { .. } => {} + SolverEvent::Completed { solution, .. } => { + completed_solution = Some(solution); + break; + } + SolverEvent::Failed { error, .. } => { + panic!("retained solve failed unexpectedly: {error}"); + } + _ => {} + } + } + + let solution = completed_solution.expect("expected a completed solution"); + assert_eq!(solution.shifts[0].employee_idx, Some(1)); + assert_eq!(solution.score, Some(HardSoftDecimalScore::ZERO)); + + MANAGER.delete(job_id).expect("delete completed job"); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..f74de4b944405bb195b55a1872fc3caf05e4e504 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,17 @@ +//! SolverForge Hospital example +//! +//! This crate is the "teachable core" of the example app. +//! +//! Beginners usually need three things when they open a SolverForge example: +//! 1. the planning model (`domain`) +//! 2. the scoring rules (`constraints`) +//! 3. the transport/runtime layer that turns a model into a running web app +//! +//! The modules below are arranged in that same order so the source reads like a +//! guided tour from optimization concepts to HTTP/UI integration. + +pub mod api; +pub mod constraints; +pub mod data; +pub mod domain; +pub mod solver; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..d688511c33e7918efaffab1b021da41b1d34ee36 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,46 @@ +//! SolverForge Hospital - Axum Server +//! +//! Run with: cargo run --release --bin solverforge-hospital +//! Then open: http://localhost:7860 + +use std::net::SocketAddr; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; + +use solverforge_hospital::api; + +#[tokio::main] +async fn main() { + // Enable the stock SolverForge console logger so local runs show phase and + // score progress without any app-specific logging glue. + solverforge::console::init(); + + let state = Arc::new(api::AppState::new()); + + // The example keeps CORS permissive because it is primarily a local demo + // app. Production deployments would usually lock this down. + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + // The router combines our backend routes, the shared SolverForge UI routes, + // and the local static files that boot the browser app. + let app = api::router(state) + .merge(solverforge_ui::routes()) + .fallback_service(ServeDir::new("static")) + .layer(cors); + + // `PORT` keeps the app easy to host on demo platforms, while `7860` stays + // as the predictable local default advertised in the docs. + let port = std::env::var("PORT") + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(7860); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + println!("SolverForge Hospital listening on http://{}", addr); + axum::serve(listener, app).await.unwrap(); +} diff --git a/src/solver/mod.rs b/src/solver/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..f75f5f7d0e895d70d9dc6ca4c6986acbe74df985 --- /dev/null +++ b/src/solver/mod.rs @@ -0,0 +1,10 @@ +//! Solver-runtime exports. +//! +//! The actual orchestration lives in `service.rs`. This module just exposes the +//! few types the HTTP layer needs, keeping the rest of the runtime machinery +//! private to the crate. + +mod service; + +pub use service::SolverService; +pub use solverforge::SolverStatus; diff --git a/src/solver/service.rs b/src/solver/service.rs new file mode 100644 index 0000000000000000000000000000000000000000..8a0d7467937056c71c3036c786eedebe443be076 --- /dev/null +++ b/src/solver/service.rs @@ -0,0 +1,201 @@ +//! Retained-job runtime orchestration and event translation. +//! +//! The app delegates actual solving to `SolverManager`. This file exists +//! to do the app-specific glue around that stock runtime: +//! - create/delete jobs +//! - expose snapshots and analysis +//! - translate stock runtime events into the JSON payload expected by the UI + +use parking_lot::RwLock; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{broadcast, mpsc}; + +use solverforge::{ + HardSoftDecimalScore, SolverEvent, SolverManager, SolverManagerError, SolverSnapshot, + SolverSnapshotAnalysis, SolverStatus, +}; + +use crate::domain::Plan; + +mod payload; + +use payload::{ + bootstrap_event_type, bootstrap_snapshot_event_type, event_payload, + snapshot_status_event_payload, status_event_payload, +}; + +static MANAGER: SolverManager = SolverManager::new(); + +/// In-memory state we keep for each live or retained job. +struct JobState { + sse_tx: broadcast::Sender, +} + +/// Small application facade over the global `SolverManager`. +pub struct SolverService { + jobs: Arc>>, +} + +impl SolverService { + /// Creates an empty job registry. The underlying runtime itself is global. + pub fn new() -> Self { + Self { + jobs: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Starts a solve and registers the broadcaster used by SSE subscribers. + pub fn start_job(&self, plan: Plan) -> Result { + let (job_id, receiver) = MANAGER.solve(plan)?; + let (sse_tx, _) = broadcast::channel(64); + + self.jobs.write().insert( + job_id, + JobState { + sse_tx: sse_tx.clone(), + }, + ); + + let jobs = Arc::clone(&self.jobs); + tokio::spawn(async move { + drain_receiver(jobs, job_id, sse_tx, receiver).await; + }); + + Ok(job_id.to_string()) + } + + /// Subscribes a browser client to future live events for a retained job. + pub fn subscribe(&self, id: &str) -> Option> { + let job_id = parse_job_id(id).ok()?; + self.jobs + .read() + .get(&job_id) + .map(|state| state.sse_tx.subscribe()) + } + + /// Builds the first SSE payload a client should see after connecting. + pub fn bootstrap_event(&self, id: &str) -> Result { + let job_id = parse_job_id(id)?; + let status = MANAGER.get_status(job_id)?; + if let Some(revision) = status.latest_snapshot_revision { + let snapshot = MANAGER.get_snapshot(job_id, Some(revision))?; + return Ok(snapshot_status_event_payload( + job_id, + bootstrap_snapshot_event_type(status.lifecycle_state), + &status, + &snapshot, + )); + } + + Ok(status_event_payload( + job_id, + bootstrap_event_type(status.lifecycle_state), + &status, + )) + } + + /// Thin pass-through to the runtime's job summary API. + pub fn get_status( + &self, + id: &str, + ) -> Result, SolverManagerError> { + MANAGER.get_status(parse_job_id(id)?) + } + + /// Requests an exact retained-runtime pause. + pub fn pause(&self, id: &str) -> Result<(), SolverManagerError> { + MANAGER.pause(parse_job_id(id)?) + } + + /// Resumes a previously paused job from its checkpoint. + pub fn resume(&self, id: &str) -> Result<(), SolverManagerError> { + MANAGER.resume(parse_job_id(id)?) + } + + /// Cancels a live or paused retained job. + pub fn cancel(&self, id: &str) -> Result<(), SolverManagerError> { + MANAGER.cancel(parse_job_id(id)?) + } + + /// Deletes a terminal job from both the runtime and the local SSE cache. + pub fn delete(&self, id: &str) -> Result<(), SolverManagerError> { + let job_id = parse_job_id(id)?; + MANAGER.delete(job_id)?; + self.jobs.write().remove(&job_id); + Ok(()) + } + + /// Fetches a retained snapshot, optionally by explicit revision. + pub fn get_snapshot( + &self, + id: &str, + snapshot_revision: Option, + ) -> Result, SolverManagerError> { + MANAGER.get_snapshot(parse_job_id(id)?, snapshot_revision) + } + + /// Runs exact constraint analysis against a retained snapshot revision. + pub fn analyze_snapshot( + &self, + id: &str, + snapshot_revision: Option, + ) -> Result, SolverManagerError> { + MANAGER.analyze_snapshot(parse_job_id(id)?, snapshot_revision) + } +} + +/// Background task that converts runtime events into serialized SSE payloads. +async fn drain_receiver( + jobs: Arc>>, + job_id: usize, + sse_tx: broadcast::Sender, + mut receiver: mpsc::UnboundedReceiver>, +) { + while let Some(event) = receiver.recv().await { + let payload = match &event { + SolverEvent::Progress { metadata } => { + event_payload(job_id, "progress", metadata, None, None) + } + SolverEvent::BestSolution { metadata, solution } => { + event_payload(job_id, "best_solution", metadata, Some(solution), None) + } + SolverEvent::PauseRequested { metadata } => { + event_payload(job_id, "pause_requested", metadata, None, None) + } + SolverEvent::Paused { metadata } => { + event_payload(job_id, "paused", metadata, None, None) + } + SolverEvent::Resumed { metadata } => { + event_payload(job_id, "resumed", metadata, None, None) + } + SolverEvent::Completed { metadata, solution } => { + event_payload(job_id, "completed", metadata, Some(solution), None) + } + SolverEvent::Cancelled { metadata } => { + event_payload(job_id, "cancelled", metadata, None, None) + } + SolverEvent::Failed { metadata, error } => { + event_payload(job_id, "failed", metadata, None, Some(error.as_str())) + } + }; + + if !jobs.read().contains_key(&job_id) { + return; + } + + let _ = sse_tx.send(payload); + } +} + +/// Parses the string job id used in HTTP routes into the runtime's numeric key. +fn parse_job_id(id: &str) -> Result { + id.parse::() + .map_err(|_| SolverManagerError::JobNotFound { job_id: usize::MAX }) +} + +impl Default for SolverService { + fn default() -> Self { + Self::new() + } +} diff --git a/src/solver/service/payload.rs b/src/solver/service/payload.rs new file mode 100644 index 0000000000000000000000000000000000000000..9e833b1c4acd745a8443fc8816dc12eaf27c2997 --- /dev/null +++ b/src/solver/service/payload.rs @@ -0,0 +1,222 @@ +//! SSE payload serialization for retained solver lifecycle events. +//! +//! `SolverService` owns job orchestration; this module owns the exact JSON shape +//! that the stock browser controller consumes over the `/events` stream and +//! during reconnect bootstrap. + +use serde::Serialize; +use std::time::Duration; + +use solverforge::{ + HardSoftDecimalScore, SolverEventMetadata, SolverLifecycleState, SolverSnapshot, SolverStatus, + SolverTelemetry, SolverTerminalReason, +}; + +use crate::api::PlanDto; +use crate::domain::Plan; + +/// UI-facing telemetry shape derived from exact runtime telemetry. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct TelemetryPayload { + elapsed_ms: u64, + step_count: u64, + moves_generated: u64, + moves_evaluated: u64, + moves_accepted: u64, + score_calculations: u64, + generation_ms: u64, + evaluation_ms: u64, + moves_per_second: u64, + acceptance_rate: f64, +} + +/// One serialized lifecycle event sent over SSE. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JobEventPayload { + id: String, + job_id: String, + event_type: &'static str, + event_sequence: u64, + lifecycle_state: &'static str, + terminal_reason: Option<&'static str>, + telemetry: TelemetryPayload, + current_score: Option, + best_score: Option, + snapshot_revision: Option, + solution: Option, + error: Option, +} + +/// Builds the synthetic event used when a client asks for current job state. +pub(super) fn status_event_payload( + job_id: usize, + event_type: &'static str, + status: &SolverStatus, +) -> String { + serialize_payload(JobEventPayload { + id: job_id.to_string(), + job_id: job_id.to_string(), + event_type, + event_sequence: status.event_sequence, + lifecycle_state: lifecycle_state_label(status.lifecycle_state), + terminal_reason: status.terminal_reason.map(terminal_reason_label), + telemetry: telemetry_payload(&status.telemetry), + current_score: status.current_score.map(|score| score.to_string()), + best_score: status.best_score.map(|score| score.to_string()), + snapshot_revision: status.latest_snapshot_revision, + solution: None, + error: None, + }) +} + +/// Builds a reconnect bootstrap event from current runtime status plus snapshot content. +pub(super) fn snapshot_status_event_payload( + job_id: usize, + event_type: &'static str, + status: &SolverStatus, + snapshot: &SolverSnapshot, +) -> String { + serialize_payload(JobEventPayload { + id: job_id.to_string(), + job_id: job_id.to_string(), + event_type, + event_sequence: status.event_sequence, + lifecycle_state: lifecycle_state_label(status.lifecycle_state), + terminal_reason: status.terminal_reason.map(terminal_reason_label), + telemetry: telemetry_payload(&status.telemetry), + current_score: status + .current_score + .or(snapshot.current_score) + .map(|score| score.to_string()), + best_score: status + .best_score + .or(snapshot.best_score) + .map(|score| score.to_string()), + snapshot_revision: Some(snapshot.snapshot_revision), + solution: Some(PlanDto::from_plan(&snapshot.solution)), + error: None, + }) +} + +/// Chooses the bootstrap event name that best matches the current lifecycle state. +pub(super) fn bootstrap_event_type(state: SolverLifecycleState) -> &'static str { + match state { + SolverLifecycleState::Solving => "progress", + SolverLifecycleState::PauseRequested => "pause_requested", + SolverLifecycleState::Paused => "paused", + SolverLifecycleState::Completed => "completed", + SolverLifecycleState::Cancelled => "cancelled", + SolverLifecycleState::Failed => "failed", + } +} + +/// Reconnecting to a live job with a retained snapshot should render that snapshot first. +pub(super) fn bootstrap_snapshot_event_type(state: SolverLifecycleState) -> &'static str { + match state { + SolverLifecycleState::Solving => "best_solution", + other => bootstrap_event_type(other), + } +} + +/// Converts one stock runtime event into the transport payload consumed by the UI. +pub(super) fn event_payload( + job_id: usize, + event_type: &'static str, + metadata: &SolverEventMetadata, + solution: Option<&Plan>, + error: Option<&str>, +) -> String { + serialize_payload(JobEventPayload { + id: job_id.to_string(), + job_id: job_id.to_string(), + event_type, + event_sequence: metadata.event_sequence, + lifecycle_state: lifecycle_state_label(metadata.lifecycle_state), + terminal_reason: metadata.terminal_reason.map(terminal_reason_label), + telemetry: telemetry_payload(&metadata.telemetry), + current_score: metadata.current_score.map(|score| score.to_string()), + best_score: metadata.best_score.map(|score| score.to_string()), + snapshot_revision: metadata.snapshot_revision, + solution: solution.map(PlanDto::from_plan), + error: error.map(ToOwned::to_owned), + }) +} + +/// Centralizes JSON serialization so every event payload uses one encoding path. +fn serialize_payload(payload: JobEventPayload) -> String { + serde_json::to_string(&payload).expect("failed to serialize solver lifecycle payload") +} + +/// Derives the browser telemetry summary from exact runtime telemetry. +fn telemetry_payload(telemetry: &SolverTelemetry) -> TelemetryPayload { + TelemetryPayload { + elapsed_ms: duration_to_millis(telemetry.elapsed), + step_count: telemetry.step_count, + moves_generated: telemetry.moves_generated, + moves_evaluated: telemetry.moves_evaluated, + moves_accepted: telemetry.moves_accepted, + score_calculations: telemetry.score_calculations, + generation_ms: duration_to_millis(telemetry.generation_time), + evaluation_ms: duration_to_millis(telemetry.evaluation_time), + moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed), + acceptance_rate: derive_acceptance_rate( + telemetry.moves_accepted, + telemetry.moves_evaluated, + ), + } +} + +/// Keeps lifecycle labels aligned with the stock runtime enums. +fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str { + match state { + SolverLifecycleState::Solving => "SOLVING", + SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED", + SolverLifecycleState::Paused => "PAUSED", + SolverLifecycleState::Completed => "COMPLETED", + SolverLifecycleState::Cancelled => "CANCELLED", + SolverLifecycleState::Failed => "FAILED", + } +} + +/// Keeps terminal-reason labels aligned with the stock runtime enums. +fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str { + match reason { + SolverTerminalReason::Completed => "completed", + SolverTerminalReason::TerminatedByConfig => "terminated_by_config", + SolverTerminalReason::Cancelled => "cancelled", + SolverTerminalReason::Failed => "failed", + } +} + +/// Converts a duration into the millisecond integer used by the UI. +fn duration_to_millis(duration: Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + +/// Reports whole evaluated moves per second for display in the status bar. +fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 { + let nanos = elapsed.as_nanos(); + if nanos == 0 { + 0 + } else { + let per_second = u128::from(count) + .saturating_mul(1_000_000_000) + .checked_div(nanos) + .unwrap_or(0); + per_second.min(u128::from(u64::MAX)) as u64 + } +} + +/// Reports acceptance as a display-ready fraction rather than a percentage string. +fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 { + if moves_evaluated == 0 { + 0.0 + } else { + moves_accepted as f64 / moves_evaluated as f64 + } +} + +#[cfg(test)] +mod tests; diff --git a/src/solver/service/payload/tests.rs b/src/solver/service/payload/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..98212a4bbe16097a40e0f84c4aa7bf0a4d9cb8d2 --- /dev/null +++ b/src/solver/service/payload/tests.rs @@ -0,0 +1,142 @@ +//! Unit tests for the retained-job SSE payload contract. + +use super::*; +use crate::domain::{Employee, Shift}; +use serde_json::Value; +use solverforge::{ + SolverLifecycleState, SolverSnapshot, SolverStatus, SolverTelemetry, SolverTerminalReason, +}; + +#[test] +fn failed_events_use_stock_payload_fields() { + let payload: Value = serde_json::from_str(&event_payload( + 7, + "failed", + &SolverEventMetadata { + job_id: 7, + event_sequence: 4, + lifecycle_state: SolverLifecycleState::Failed, + terminal_reason: Some(SolverTerminalReason::Failed), + snapshot_revision: Some(3), + current_score: None, + best_score: None, + telemetry: SolverTelemetry::default(), + }, + None, + Some("boom"), + )) + .unwrap(); + + assert_eq!(payload["id"], "7"); + assert_eq!(payload["jobId"], "7"); + assert_eq!(payload["eventType"], "failed"); + assert_eq!(payload["lifecycleState"], "FAILED"); + assert_eq!(payload["terminalReason"], "failed"); + assert_eq!(payload["error"], "boom"); +} + +#[test] +fn event_payload_derives_stock_telemetry_fields_from_exact_runtime_telemetry() { + let payload: Value = serde_json::from_str(&event_payload( + 5, + "progress", + &SolverEventMetadata { + job_id: 5, + event_sequence: 2, + lifecycle_state: SolverLifecycleState::Solving, + terminal_reason: None, + snapshot_revision: Some(1), + current_score: Some(HardSoftDecimalScore::ZERO), + best_score: Some(HardSoftDecimalScore::ZERO), + telemetry: SolverTelemetry { + elapsed: std::time::Duration::from_millis(2_500), + step_count: 9, + moves_generated: 300, + moves_evaluated: 200, + moves_accepted: 50, + score_calculations: 80, + generation_time: std::time::Duration::from_millis(400), + evaluation_time: std::time::Duration::from_millis(900), + ..SolverTelemetry::default() + }, + }, + None, + None, + )) + .unwrap(); + + assert_eq!(payload["telemetry"]["elapsedMs"], 2500); + assert_eq!(payload["telemetry"]["stepCount"], 9); + assert_eq!(payload["telemetry"]["movesGenerated"], 300); + assert_eq!(payload["telemetry"]["movesEvaluated"], 200); + assert_eq!(payload["telemetry"]["movesAccepted"], 50); + assert_eq!(payload["telemetry"]["scoreCalculations"], 80); + assert_eq!(payload["telemetry"]["generationMs"], 400); + assert_eq!(payload["telemetry"]["evaluationMs"], 900); + assert_eq!(payload["telemetry"]["movesPerSecond"], 80); + assert_eq!(payload["telemetry"]["acceptanceRate"], 0.25); +} + +#[test] +fn snapshot_bootstrap_payload_exposes_live_solution_and_ui_score_fields() { + let mut solution = Plan::new( + vec![Employee::new(0, "Alex").with_skill("Doctor")], + vec![{ + let mut shift = Shift::new( + "shift-1", + chrono::NaiveDate::from_ymd_opt(2024, 1, 1) + .unwrap() + .and_hms_opt(8, 0, 0) + .unwrap(), + chrono::NaiveDate::from_ymd_opt(2024, 1, 1) + .unwrap() + .and_hms_opt(16, 0, 0) + .unwrap(), + "ER", + "Doctor", + ); + shift.employee_idx = Some(0); + shift + }], + ); + solution.score = Some(HardSoftDecimalScore::ZERO); + + let status = SolverStatus { + job_id: 11, + lifecycle_state: SolverLifecycleState::Solving, + terminal_reason: None, + checkpoint_available: true, + event_sequence: 9, + latest_snapshot_revision: Some(4), + current_score: None, + best_score: None, + telemetry: SolverTelemetry::default(), + }; + let snapshot = SolverSnapshot { + job_id: 11, + snapshot_revision: 4, + lifecycle_state: SolverLifecycleState::Solving, + terminal_reason: None, + current_score: Some(HardSoftDecimalScore::ZERO), + best_score: Some(HardSoftDecimalScore::ZERO), + telemetry: SolverTelemetry::default(), + solution, + }; + + let payload: Value = serde_json::from_str(&snapshot_status_event_payload( + 11, + bootstrap_snapshot_event_type(status.lifecycle_state), + &status, + &snapshot, + )) + .unwrap(); + + assert_eq!(payload["eventType"], "best_solution"); + assert_eq!(payload["lifecycleState"], "SOLVING"); + assert_eq!(payload["snapshotRevision"], 4); + assert_eq!(payload["currentScore"], "0hard/0soft"); + assert_eq!(payload["bestScore"], "0hard/0soft"); + assert!(payload["solution"].is_object()); + assert_eq!(payload["solution"]["shifts"][0]["employeeIdx"], 0); + assert_eq!(payload["solution"]["score"], "0hard/0soft"); +} diff --git a/static/app/main.mjs b/static/app/main.mjs new file mode 100644 index 0000000000000000000000000000000000000000..4c76c0ceb078414a749080dccd29e2d4964b563b --- /dev/null +++ b/static/app/main.mjs @@ -0,0 +1,130 @@ +import { buildAnalysisBody } from './schedule/analysis-modal.mjs'; +import { createAppShell } from './shell/app-shell.mjs'; +import { createAppState } from './shell/app-state.mjs'; +import { loadAppConfig } from './shell/config-loader.mjs'; +import { renderDataTables } from './shell/data-panel.mjs'; +import { createSolverController } from './shell/solver-controller.mjs'; +import { createViewRegistry } from './views/registry.mjs'; + +// Browser entrypoint that wires together config loading, the shared UI shell, +// hospital-specific view renderers, and the retained-job controller. +export async function bootApp(root = globalThis) { + const document = root.document; + const sf = root.SF; + const appElement = document && document.getElementById('sf-app'); + if (!document || !appElement) return null; + if (!sf) { + throw new Error('SolverForge UI must be loaded before bootApp()'); + } + + const { config, uiModel, backend, demoId } = await loadAppConfig(root); + const state = createAppState(uiModel.views[0].id); + const statusBar = sf.createStatusBar({ constraints: uiModel.constraints }); + const shell = createAppShell({ + root, + sf, + appElement, + config, + uiModel, + demoId, + statusBar, + activeTab: state.activeTab, + actions: { + onSolve: () => startSolve(), + onPause: () => controller.pause(), + onResume: () => controller.resume(), + onCancel: () => controller.cancel(), + onAnalyze: () => openAnalysis(), + onTabChange(tabId) { + state.activeTab = tabId; + }, + }, + }); + const views = createViewRegistry(); + const controller = createSolverController({ + sf, + backend, + statusBar, + onPlan(plan) { + renderAll(plan); + }, + onAnalysis() {}, + onMeta() {}, + onLifecycle(markers) { + shell.syncLifecycleMarkers(markers); + }, + onError(error) { + root.console.error('Solver lifecycle failed:', error); + }, + }); + + try { + const demoData = await backend.getDemoData(demoId); + renderAll(demoData); + } catch (error) { + root.console.error('Initial demo load failed:', error); + } + + return { + backend, + controller, + shell, + state, + uiModel, + }; + + // Re-renders both schedule tabs and the raw data tables from the latest plan. + function renderAll(data) { + state.currentPlan = clonePlan(data); + renderViews(data); + renderDataTables({ sf, container: shell.dataRoot, uiModel, data }); + } + + // Dispatches each configured view to the renderer registered for its `kind`. + function renderViews(data) { + uiModel.views.forEach((view) => { + const container = shell.viewRoots[view.id]; + const renderView = views[view.kind]; + if (!container) return; + container.innerHTML = ''; + if (!renderView) { + container.appendChild(sf.el('p', null, `No renderer is registered for ${view.kind}.`)); + return; + } + renderView({ sf, container, data, view }); + }); + } + + // Starts solving from the last rendered plan snapshot. + async function startSolve() { + const plan = await resolvePlanForSolve(); + await controller.start(() => Promise.resolve(clonePlan(plan))); + } + + // Lazily loads demo data if the user clicks Solve before the first fetch finishes. + async function resolvePlanForSolve() { + if (state.currentPlan) { + return state.currentPlan; + } + + const demoData = await backend.getDemoData(demoId); + renderAll(demoData); + return state.currentPlan; + } + + // Fetches exact retained-snapshot analysis and opens it in the shared modal. + async function openAnalysis() { + if (!controller.getJobId()) return Promise.resolve(); + try { + const currentAnalysis = await controller.analyzeSnapshot(); + shell.openAnalysis(buildAnalysisBody(document, currentAnalysis, uiModel.constraints)); + } catch (error) { + root.console.error('Analysis failed:', error); + } + } +} + +// Defensive deep clone so the UI never mutates the last backend payload in place. +function clonePlan(data) { + return JSON.parse(JSON.stringify(data)); +} diff --git a/static/app/schedule/analysis-modal.mjs b/static/app/schedule/analysis-modal.mjs new file mode 100644 index 0000000000000000000000000000000000000000..5472a710206104a705629baea1ffcf325de1f1f8 --- /dev/null +++ b/static/app/schedule/analysis-modal.mjs @@ -0,0 +1,79 @@ +// Accept either the raw analysis body or the `/analysis` wrapper returned by the backend. +function normalizeAnalysis(analysis) { + return analysis && analysis.analysis ? analysis.analysis : analysis; +} + +// Lets the modal recover constraint type labels even when the backend response omits them. +function constraintTypeLookup(constraints) { + return new Map((constraints || []).map((constraint) => [constraint.name, constraint.type])); +} + +// Renders one analysis table into the modal container. +function appendAnalysisSection(document, container, title, analysis, constraintTypes) { + const analysisBody = normalizeAnalysis(analysis); + if (!analysisBody || !Array.isArray(analysisBody.constraints)) return; + + if (container.childNodes.length) { + container.appendChild(document.createElement('hr')); + } + + const heading = document.createElement('h3'); + heading.textContent = title; + container.appendChild(heading); + + const scoreLine = document.createElement('p'); + const scoreLabel = document.createElement('strong'); + scoreLabel.textContent = 'Score:'; + scoreLine.appendChild(scoreLabel); + scoreLine.appendChild(document.createTextNode(` ${String(analysisBody.score || '—')}`)); + container.appendChild(scoreLine); + + const table = document.createElement('table'); + table.className = 'sf-table'; + + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + ['Constraint', 'Type', 'Score', 'Matches'].forEach((label) => { + const cell = document.createElement('th'); + cell.textContent = label; + headerRow.appendChild(cell); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + analysisBody.constraints.forEach((constraint) => { + const row = document.createElement('tr'); + [ + constraint.name || '', + constraint.constraintType || constraint.type || constraintTypes.get(constraint.name) || '', + constraint.score || '', + String(constraint.matchCount != null ? constraint.matchCount : 0), + ].forEach((value) => { + const cell = document.createElement('td'); + cell.textContent = String(value); + row.appendChild(cell); + }); + tbody.appendChild(row); + }); + table.appendChild(tbody); + container.appendChild(table); +} + +// Builds DOM content instead of raw HTML so tests can verify escaping and structure. +export function buildAnalysisBody(document, analysis, constraints = []) { + const analysisBody = normalizeAnalysis(analysis); + const constraintTypes = constraintTypeLookup(constraints); + const container = document.createElement('div'); + + if (!analysisBody || !Array.isArray(analysisBody.constraints)) { + const empty = document.createElement('p'); + empty.textContent = 'No analysis available.'; + container.appendChild(empty); + return container; + } + + appendAnalysisSection(document, container, 'Current retained snapshot', analysisBody, constraintTypes); + + return container; +} diff --git a/static/app/schedule/datetime.mjs b/static/app/schedule/datetime.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ff8d502b8ae314408d734821d9f91f1c0ee89a22 --- /dev/null +++ b/static/app/schedule/datetime.mjs @@ -0,0 +1,60 @@ +export const MINUTE_MS = 60 * 1000; +export const DAY_MS = 24 * 60 * MINUTE_MS; + +const WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +// Short human-readable label used inside schedule blocks. +export function compactDateTime(value) { + if (value == null) return ''; + return String(value) + .replace('T', ' ') + .replace(/(\d{2}:\d{2}):\d{2}$/, '$1'); +} + +// Parses backend `NaiveDateTime` strings without letting the browser inject the local timezone. +export function parseDateTimeMs(value) { + if (value == null || value === '') return null; + if (typeof value === 'number' && Number.isFinite(value)) return value; + + const normalized = String(value).trim().replace(' ', 'T'); + const match = normalized.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/); + if (match) { + // Preserve backend NaiveDateTime wall-time components without applying the browser timezone. + return Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4] || 0), + Number(match[5] || 0), + Number(match[6] || 0), + ); + } + + const parsed = Date.parse(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +// Guarantees a usable time span for timeline rendering even when data is partial. +export function normalizeShiftBounds(startMs, endMs) { + if (startMs == null && endMs == null) { + return { startMs: null, endMs: null }; + } + let resolvedStart = startMs; + let resolvedEnd = endMs; + if (resolvedStart == null) resolvedStart = resolvedEnd; + if (resolvedEnd == null || resolvedEnd <= resolvedStart) resolvedEnd = resolvedStart + MINUTE_MS; + return { startMs: resolvedStart, endMs: resolvedEnd }; +} + +// Returns the UTC midnight used as the start of the visible wall-time day. +export function wallTimeDayStartMs(ms) { + const date = new Date(ms); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +// Formats the day header shown above the timeline. +export function formatAxisDayLabel(ms) { + const date = new Date(ms); + return `${WEEKDAY_NAMES[date.getUTCDay()]} ${date.getUTCDate()} ${MONTH_NAMES[date.getUTCMonth()]}`; +} diff --git a/static/app/schedule/employee-view.mjs b/static/app/schedule/employee-view.mjs new file mode 100644 index 0000000000000000000000000000000000000000..6fc899fcf3d466349fb02154c8788b2ecc53958a --- /dev/null +++ b/static/app/schedule/employee-view.mjs @@ -0,0 +1,95 @@ +import { groupShiftRowsByEmployee } from './grouping.mjs'; +import { buildShiftPresentation } from './presentation.mjs'; +import { assignmentTone, buildBlockMeta, renderEmptyScheduleMessage, renderRailSchedule } from './rail-renderer.mjs'; +import { parseDateTimeMs, wallTimeDayStartMs } from './datetime.mjs'; + +// Renders the "By employee" schedule tab on top of the shared rail timeline. +export function renderEmployeeView({ sf, container, data, view }) { + const shifts = data[view.entityPlural] || []; + const employees = data[view.sourcePlural] || []; + if (!shifts.length) { + renderEmptyScheduleMessage(sf, container); + return; + } + + const presentation = buildShiftPresentation(shifts, employees, view.variableField); + const grouped = groupShiftRowsByEmployee(presentation.rows, employees); + const lanes = grouped.groups.map((group) => ({ + id: `${view.id}-employee-${group.key}`, + name: group.label, + mode: 'detailed', + badges: group.badges || [], + stats: [{ label: 'Shifts', value: group.rows.length }], + overlays: buildEmployeeOverlays(group.employee, presentation.axis), + rows: group.rows, + presentRow(row) { + return { + label: row.locationLabel, + meta: buildBlockMeta(row), + tone: assignmentTone(row.locationLabel, true), + }; + }, + })); + + if (grouped.unassignedRows.length) { + lanes.push({ + id: `${view.id}-employee-unassigned`, + name: 'Unassigned shifts', + mode: 'detailed', + badges: ['Needs assignment'], + stats: [{ label: 'Shifts', value: grouped.unassignedRows.length }], + rows: grouped.unassignedRows, + presentRow(row) { + return { + label: row.locationLabel, + meta: buildBlockMeta(row), + tone: assignmentTone(row.locationLabel, false), + }; + }, + }); + } + + renderRailSchedule({ + sf, + container, + axis: presentation.axis, + headerLabel: 'Employee', + lanes, + unassignedCount: presentation.unassignedCount, + }); +} + +// Builds colored day overlays from employee unavailability and preferences. +function buildEmployeeOverlays(employee, axis) { + if (!employee || !axis || !Array.isArray(axis.columns) || !axis.columns.length) return []; + + const dayIndexByStartMs = new Map( + axis.columns.map((column, index) => [column.startMs, index]), + ); + + return [] + .concat(buildDayOverlays(employee.unavailableDates, 'Unavailable', 'red', dayIndexByStartMs)) + .concat(buildDayOverlays(employee.undesiredDates, 'Undesired', 'amber', dayIndexByStartMs)) + .concat(buildDayOverlays(employee.desiredDates, 'Desired', 'emerald', dayIndexByStartMs)); +} + +// Converts a list of dates into one-day overlay blocks on the timeline axis. +function buildDayOverlays(values, label, tone, dayIndexByStartMs) { + if (!Array.isArray(values) || !values.length) return []; + + return values.map((value, index) => { + const parsed = parseDateTimeMs(value); + if (parsed == null) return null; + + const dayIndex = dayIndexByStartMs.get(wallTimeDayStartMs(parsed)); + if (dayIndex == null) return null; + + return { + id: `${label.toLowerCase()}-${dayIndex}-${index}`, + dayIndex, + dayCount: 1, + label, + tone, + }; + }).filter(Boolean); +} diff --git a/static/app/schedule/grouping.mjs b/static/app/schedule/grouping.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2b16010d38138d4821957e8d94570384a8c69e0e --- /dev/null +++ b/static/app/schedule/grouping.mjs @@ -0,0 +1,77 @@ +import { countDisplayLabels, displayLabel, factKey } from './identity.mjs'; +import { compareShiftRows } from './presentation.mjs'; + +// Groups already-presented shift rows by location while preserving lane order. +export function groupShiftRowsByLocation(rows = []) { + const groupsByKey = {}; + + rows.forEach((row) => { + const groupKey = `location:${String(row.locationLabel)}`; + if (!groupsByKey[groupKey]) { + groupsByKey[groupKey] = { + key: groupKey, + label: row.locationLabel, + rows: [], + sourceIndex: row.shiftIndex, + }; + } else { + groupsByKey[groupKey].sourceIndex = Math.min(groupsByKey[groupKey].sourceIndex, row.shiftIndex); + } + groupsByKey[groupKey].rows.push(row); + }); + + return Object.keys(groupsByKey) + .sort((left, right) => groupsByKey[left].sourceIndex - groupsByKey[right].sourceIndex) + .map((key) => { + const group = groupsByKey[key]; + group.rows.sort(compareShiftRows); + return group; + }); +} + +// Groups rows by employee and preserves empty employee lanes for visibility. +export function groupShiftRowsByEmployee(rows = [], employees = []) { + const buckets = {}; + const groups = []; + const unassignedRows = []; + const labelCounts = countDisplayLabels(employees, 'Employee'); + + rows.forEach((row) => { + if (!row.isAssigned || !row.employeeKey) { + unassignedRows.push(row); + return; + } + if (!buckets[row.employeeKey]) { + buckets[row.employeeKey] = { + key: row.employeeKey, + label: row.employeeLabel, + employee: row.employee || null, + rows: [], + }; + } + buckets[row.employeeKey].rows.push(row); + }); + + employees.forEach((employee, index) => { + const key = factKey(employee, index); + const label = displayLabel(employee, index); + const group = buckets[key] || { + key, + label, + employee: employee || null, + rows: [], + }; + group.rows.sort(compareShiftRows); + group.badges = labelCounts[label] > 1 && employee && employee.id != null + ? [String(employee.id)] + : []; + groups.push(group); + }); + + unassignedRows.sort(compareShiftRows); + + return { + groups, + unassignedRows, + }; +} diff --git a/static/app/schedule/identity.mjs b/static/app/schedule/identity.mjs new file mode 100644 index 0000000000000000000000000000000000000000..f9de42e553c779aac84d2231ea03ff4e221f81c7 --- /dev/null +++ b/static/app/schedule/identity.mjs @@ -0,0 +1,33 @@ +// Builds a stable key that survives duplicate display names in the UI. +export function stableIdentityKey(kind, item, fallback) { + if (item && item.id != null && String(item.id) !== '') { + return `${kind}-id:${String(item.id)}`; + } + return `${kind}-index:${String(fallback)}`; +} + +// Stable key for problem facts such as employees. +export function factKey(fact, fallback) { + return stableIdentityKey('fact', fact, fallback); +} + +// Stable key for planning entities such as shifts. +export function entityKey(entity, fallback) { + return stableIdentityKey('entity', entity, fallback); +} + +// Human-readable label shown to the user for an item. +export function displayLabel(item, fallback) { + if (!item) return String(fallback); + return item.name || item.id || fallback; +} + +// Counts duplicate display labels so the UI can add badges when names collide. +export function countDisplayLabels(items, fallbackPrefix) { + const counts = {}; + (items || []).forEach((item, index) => { + const label = displayLabel(item, `${fallbackPrefix} ${index + 1}`); + counts[label] = (counts[label] || 0) + 1; + }); + return counts; +} diff --git a/static/app/schedule/location-view.mjs b/static/app/schedule/location-view.mjs new file mode 100644 index 0000000000000000000000000000000000000000..42e0d982227243019cef7177ec2076bd54dddde6 --- /dev/null +++ b/static/app/schedule/location-view.mjs @@ -0,0 +1,42 @@ +import { groupShiftRowsByLocation } from './grouping.mjs'; +import { buildShiftPresentation } from './presentation.mjs'; +import { assignmentTone, buildBlockMeta, renderEmptyScheduleMessage, renderRailSchedule } from './rail-renderer.mjs'; + +// Renders the "By location" schedule tab on top of the shared rail timeline. +export function renderLocationView({ sf, container, data, view }) { + const shifts = data[view.entityPlural] || []; + const employees = data[view.sourcePlural] || []; + if (!shifts.length) { + renderEmptyScheduleMessage(sf, container); + return; + } + + const presentation = buildShiftPresentation(shifts, employees, view.variableField); + const groups = groupShiftRowsByLocation(presentation.rows); + const lanes = groups.map((group) => ({ + id: `${view.id}-location-${group.key}`, + name: group.label, + mode: 'detailed', + stats: [ + { label: 'Shifts', value: group.rows.length }, + { label: 'Open', value: group.rows.reduce((count, row) => count + (row.isAssigned ? 0 : 1), 0) }, + ], + rows: group.rows, + presentRow(row) { + return { + label: row.employeeLabel, + meta: buildBlockMeta(row), + tone: assignmentTone(row.employeeLabel, row.isAssigned), + }; + }, + })); + + renderRailSchedule({ + sf, + container, + axis: presentation.axis, + headerLabel: 'Location', + lanes, + unassignedCount: presentation.unassignedCount, + }); +} diff --git a/static/app/schedule/presentation.mjs b/static/app/schedule/presentation.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2c8e17d6f0ad2a5dc56f562917e7d0abecb8461b --- /dev/null +++ b/static/app/schedule/presentation.mjs @@ -0,0 +1,116 @@ +import { compactDateTime, normalizeShiftBounds, parseDateTimeMs, MINUTE_MS, DAY_MS, wallTimeDayStartMs, formatAxisDayLabel } from './datetime.mjs'; +import { entityKey, factKey, displayLabel } from './identity.mjs'; + +// Derives the visible day-aligned timeline axis from the current shift rows. +function buildScheduleAxis(rows) { + const timedRows = (rows || []).filter((row) => row.startMs != null && row.endMs != null); + if (!timedRows.length) { + return { + horizonStartMs: 0, + horizonEndMs: DAY_MS, + horizonMinutes: DAY_MS / MINUTE_MS, + columns: [{ label: 'Schedule', startMs: 0, endMs: DAY_MS }], + }; + } + + const minStartMs = timedRows.reduce((min, row) => Math.min(min, row.startMs), timedRows[0].startMs); + const maxEndMs = timedRows.reduce((max, row) => Math.max(max, row.endMs), timedRows[0].endMs); + const displayEndMs = maxEndMs > minStartMs ? maxEndMs - 1 : maxEndMs; + const horizonStartMs = wallTimeDayStartMs(minStartMs); + const horizonEndMs = wallTimeDayStartMs(displayEndMs) + DAY_MS; + const dayCount = Math.max(1, Math.round((horizonEndMs - horizonStartMs) / DAY_MS)); + const columns = []; + + for (let dayIndex = 0; dayIndex < dayCount; dayIndex += 1) { + const columnStartMs = horizonStartMs + (dayIndex * DAY_MS); + columns.push({ + label: formatAxisDayLabel(columnStartMs), + startMs: columnStartMs, + endMs: columnStartMs + DAY_MS, + }); + } + + return { + horizonStartMs, + horizonEndMs, + horizonMinutes: Math.max(1, Math.round((horizonEndMs - horizonStartMs) / MINUTE_MS)), + columns, + }; +} + +// Consistent ordering shared by grouping and rendering. +export function compareShiftRows(a, b) { + if (a.startSortKey !== b.startSortKey) { + return String(a.startSortKey).localeCompare(String(b.startSortKey)); + } + if (a.endSortKey !== b.endSortKey) { + return String(a.endSortKey).localeCompare(String(b.endSortKey)); + } + if (a.locationLabel !== b.locationLabel) { + return String(a.locationLabel).localeCompare(String(b.locationLabel)); + } + return String(a.shiftKey).localeCompare(String(b.shiftKey)); +} + +// Converts raw transport data into render-friendly rows plus a shared axis model. +export function buildShiftPresentation(shifts = [], employees = [], variableField = 'employeeIdx') { + const employeeByIndex = {}; + employees.forEach((employee, index) => { + employeeByIndex[index] = employee; + }); + + let rows = shifts.map((shift, shiftIndex) => { + const employeeIndex = shift[variableField]; + const employee = employeeIndex == null ? null : employeeByIndex[employeeIndex] || null; + const bounds = normalizeShiftBounds(parseDateTimeMs(shift.start), parseDateTimeMs(shift.end)); + const startLabel = compactDateTime(shift.start); + const endLabel = compactDateTime(shift.end); + const timeLabel = startLabel && endLabel + ? `${startLabel} → ${endLabel}` + : startLabel || endLabel || 'Unscheduled'; + + return { + shift, + shiftIndex, + shiftKey: entityKey(shift, shiftIndex), + employee, + employeeIndex: employee == null ? null : employeeIndex, + employeeKey: employee == null ? null : factKey(employee, employeeIndex), + employeeLabel: employee == null ? 'Unassigned' : displayLabel(employee, employeeIndex), + isAssigned: employee != null, + locationLabel: shift.location || 'Unspecified location', + requiredSkill: shift.requiredSkill || '', + startLabel, + endLabel, + startMs: bounds.startMs, + endMs: bounds.endMs, + startSortKey: shift.start || '', + endSortKey: shift.end || '', + timeLabel, + }; + }); + + const axis = buildScheduleAxis(rows); + rows = rows.map((row) => { + const startOffsetMinutes = row.startMs == null + ? 0 + : Math.max(0, Math.round((row.startMs - axis.horizonStartMs) / MINUTE_MS)); + const endOffsetMinutes = row.endMs == null + ? startOffsetMinutes + 1 + : Math.max(startOffsetMinutes + 1, Math.round((row.endMs - axis.horizonStartMs) / MINUTE_MS)); + const clampedEndOffsetMinutes = Math.min(axis.horizonMinutes, endOffsetMinutes); + + return { + ...row, + startOffsetMinutes, + endOffsetMinutes: Math.max(startOffsetMinutes + 1, clampedEndOffsetMinutes), + durationMinutes: Math.max(1, clampedEndOffsetMinutes - startOffsetMinutes), + }; + }).sort(compareShiftRows); + + return { + rows, + unassignedCount: rows.reduce((count, row) => count + (row.isAssigned ? 0 : 1), 0), + axis, + }; +} diff --git a/static/app/schedule/rail-renderer.mjs b/static/app/schedule/rail-renderer.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2b9205e15d5272fec1b020669e1a67385703632f --- /dev/null +++ b/static/app/schedule/rail-renderer.mjs @@ -0,0 +1,154 @@ +import { DAY_MS, MINUTE_MS } from './datetime.mjs'; + +const DEFAULT_LABEL_WIDTH = 280; +const DEFAULT_VIEWPORT_DAYS = 14; +const SIX_HOUR_MINUTES = 6 * 60; + +// Picks a deterministic color family from a row label so the UI feels stable. +export function assignmentTone(label, isAssigned) { + if (!isAssigned) return 'red'; + + const palette = ['blue', 'emerald', 'amber', 'cyan', 'violet', 'slate']; + const key = String(label || ''); + let hash = 0; + for (let index = 0; index < key.length; index += 1) { + hash = ((hash * 31) + key.charCodeAt(index)) >>> 0; + } + return palette[hash % palette.length]; +} + +// Shared metadata rows shown inside detailed timeline blocks. +export function buildBlockMeta(row) { + return [ + { label: 'Time', value: row.timeLabel }, + { label: 'Skill', value: row.requiredSkill || 'Unspecified' }, + ].filter((entry) => entry.value); +} + +// Placeholder shown when a view has no shifts yet. +export function renderEmptyScheduleMessage(sf, container) { + container.appendChild(sf.el('p', null, 'This schedule view will appear once shifts are available.')); +} + +// Adapts the hospital schedule model to the shared `SF.rail.createTimeline()` widget. +export function renderRailSchedule({ sf, container, axis, headerLabel, lanes }) { + const model = { + axis: buildTimelineAxis(axis), + lanes: lanes.map((lane) => buildTimelineLaneModel(lane)), + }; + + const timeline = sf.rail.createTimeline({ + label: headerLabel, + labelWidth: DEFAULT_LABEL_WIDTH, + model, + title: `${headerLabel} schedule`, + subtitle: 'Drag the day header or lane body to pan horizontally.', + }); + + container.appendChild(timeline.el); + timeline.setViewport(model.axis.initialViewport); + + return timeline; +} + +// Converts the hospital day axis into the timeline widget's minute-based model. +function buildTimelineAxis(axis) { + const columns = Array.isArray(axis && axis.columns) ? axis.columns : []; + if (!columns.length) { + const endMinute = DAY_MS / MINUTE_MS; + return { + startMinute: 0, + endMinute, + days: [{ label: 'Schedule', startMinute: 0, endMinute }], + ticks: buildTicks(endMinute), + initialViewport: { startMinute: 0, endMinute }, + }; + } + + const horizonStartMs = Number(axis.horizonStartMs || columns[0].startMs || 0); + const endMinute = Math.max(1, Number(axis.horizonMinutes || 1)); + const days = columns.map((column, index) => { + const startMinute = msToMinuteOffset(column.startMs, horizonStartMs); + const fallbackEndMinute = index === columns.length - 1 + ? endMinute + : msToMinuteOffset(columns[index + 1].startMs, horizonStartMs); + const nextEndMinute = column.endMs == null + ? fallbackEndMinute + : msToMinuteOffset(column.endMs, horizonStartMs); + + return { + label: column.label, + startMinute, + endMinute: Math.max(startMinute + 1, nextEndMinute), + isWeekend: /^(Sat|Sun)\b/.test(String(column.label || '')), + }; + }); + + return { + startMinute: 0, + endMinute, + days, + ticks: buildTicks(endMinute), + initialViewport: { + startMinute: 0, + endMinute: Math.min(endMinute, DEFAULT_VIEWPORT_DAYS * 24 * 60), + }, + }; +} + +// Converts one lane into the shared timeline lane shape. +function buildTimelineLaneModel(lane) { + return { + id: lane.id, + label: lane.name, + mode: lane.mode === 'overview' ? 'overview' : 'detailed', + badges: lane.badges || [], + stats: lane.stats || [], + items: buildTimelineItems(lane), + overlays: lane.overlays || [], + }; +} + +// Converts all rows in a lane into timeline items. +function buildTimelineItems(lane) { + return (lane.rows || []).map((row) => toTimelineItem(lane, row)); +} + +// Converts one presented shift row into a rail item. +function toTimelineItem(lane, row) { + const display = lane.presentRow(row); + return { + id: `${lane.id}-shift-${row.shiftKey}`, + startMinute: row.startOffsetMinutes, + endMinute: row.endOffsetMinutes, + label: display.label, + meta: display.meta || '', + summary: display.summary || null, + tone: display.tone || display.color || 'slate', + clusterId: display.clusterId || null, + }; +} + +// Adds six-hour tick marks across the visible horizon. +function buildTicks(endMinute) { + const ticks = []; + for (let minute = 0; minute < endMinute; minute += SIX_HOUR_MINUTES) { + ticks.push({ + minute, + label: formatClock(minute), + }); + } + return ticks; +} + +// Formats a minute offset as a simple hour label. +function formatClock(totalMinutes) { + const hour = Math.floor(totalMinutes / 60) % 24; + return `${String(hour).padStart(2, '0')}:00`; +} + +// Converts absolute milliseconds into minute offsets within the current horizon. +function msToMinuteOffset(value, horizonStartMs) { + if (value == null) return 0; + return Math.max(0, Math.round((Number(value) - horizonStartMs) / MINUTE_MS)); +} diff --git a/static/app/shell/api-guide.mjs b/static/app/shell/api-guide.mjs new file mode 100644 index 0000000000000000000000000000000000000000..26838f09c7c4d1e406961e6a0efadd592ab732be --- /dev/null +++ b/static/app/shell/api-guide.mjs @@ -0,0 +1,104 @@ +// This module is part of the public documentation surface because the browser +// renders its output directly in the "REST API" guide. + +// Chooses the origin used in the curl examples shown in the API guide. +function resolveBaseUrl(root) { + const location = root.location || (root.window && root.window.location); + return location && location.origin ? location.origin : 'http://localhost'; +} + +// Small helper that keeps the curl examples readable. +function curl(method, baseUrl, path, suffix = '') { + return `curl${method === 'GET' ? '' : ` -X ${method}`}${suffix} ${baseUrl}${path}`; +} + +// Builds the endpoint list rendered in the "REST API" tab. +export function buildApiGuideEndpoints(demoId, root = globalThis) { + const baseUrl = resolveBaseUrl(root); + return [ + { + method: 'GET', + path: '/health', + description: 'Liveness probe', + curl: curl('GET', baseUrl, '/health'), + }, + { + method: 'GET', + path: '/info', + description: 'App identity and version', + curl: curl('GET', baseUrl, '/info'), + }, + { + method: 'GET', + path: '/demo-data', + description: 'List available demo dataset ids', + curl: curl('GET', baseUrl, '/demo-data'), + }, + { + method: 'GET', + path: `/demo-data/${demoId}`, + description: 'Fetch one demo dataset', + curl: curl('GET', baseUrl, `/demo-data/${demoId}`), + }, + { + method: 'POST', + path: '/jobs', + description: 'Create a retained solving job', + curl: `${curl('POST', baseUrl, '/jobs', ' -H "Content-Type: application/json"')} -d @plan.json`, + }, + { + method: 'GET', + path: '/jobs/{id}', + description: 'Get current job summary', + curl: curl('GET', baseUrl, '/jobs/{id}'), + }, + { + method: 'GET', + path: '/jobs/{id}/status', + description: 'Alias for the current job summary', + curl: curl('GET', baseUrl, '/jobs/{id}/status'), + }, + { + method: 'GET', + path: '/jobs/{id}/snapshot', + description: 'Fetch the latest or an exact retained snapshot', + curl: curl('GET', baseUrl, '/jobs/{id}/snapshot'), + }, + { + method: 'GET', + path: '/jobs/{id}/analysis?snapshot_revision={n}', + description: 'Analyze an exact snapshot revision', + curl: `${curl('GET', baseUrl, '/jobs/{id}/analysis')}?snapshot_revision=3`, + }, + { + method: 'POST', + path: '/jobs/{id}/pause', + description: 'Request an exact runtime pause', + curl: curl('POST', baseUrl, '/jobs/{id}/pause'), + }, + { + method: 'POST', + path: '/jobs/{id}/resume', + description: 'Resume a paused retained job', + curl: curl('POST', baseUrl, '/jobs/{id}/resume'), + }, + { + method: 'POST', + path: '/jobs/{id}/cancel', + description: 'Stop a live or paused job through runtime cancel', + curl: curl('POST', baseUrl, '/jobs/{id}/cancel'), + }, + { + method: 'DELETE', + path: '/jobs/{id}', + description: 'Delete a terminal retained job', + curl: curl('DELETE', baseUrl, '/jobs/{id}'), + }, + { + method: 'GET', + path: '/jobs/{id}/events', + description: 'Stream job lifecycle updates (SSE)', + curl: `curl -N ${baseUrl}/jobs/{id}/events`, + }, + ]; +} diff --git a/static/app/shell/app-shell.mjs b/static/app/shell/app-shell.mjs new file mode 100644 index 0000000000000000000000000000000000000000..28e62696c2e615ddf112a45c9271c5fcfde1c9b2 --- /dev/null +++ b/static/app/shell/app-shell.mjs @@ -0,0 +1,103 @@ +import { buildApiGuideEndpoints } from './api-guide.mjs'; + +// Builds the header tabs from the generated views plus the local utility panels. +function buildTabs(uiModel) { + const tabs = uiModel.views.map((view, index) => ({ + id: view.id, + label: view.label, + icon: 'fa-table-cells-large', + active: index === 0, + })); + tabs.push({ id: 'data', label: 'Data', icon: 'fa-table' }); + tabs.push({ id: 'api', label: 'REST API', icon: 'fa-book' }); + return tabs; +} + +// Creates the shared SolverForge UI shell and the hospital-specific panels. +export function createAppShell({ + root = globalThis, + sf, + appElement, + config, + uiModel, + demoId, + statusBar, + activeTab, + actions, +}) { + const viewRoots = {}; + const header = sf.createHeader({ + logo: '/sf/img/ouroboros.svg', + title: config.title, + subtitle: config.subtitle, + tabs: buildTabs(uiModel), + actions, + onTabChange(tabId) { + shell.setActiveTab(tabId); + if (typeof actions.onTabChange === 'function') actions.onTabChange(tabId); + }, + }); + appElement.appendChild(header); + statusBar.bindHeader(header); + appElement.appendChild(statusBar.el); + + uiModel.views.forEach((view) => { + const panel = sf.el('div', { className: 'sf-content', style: { display: 'none' } }); + const rootEl = sf.el('div', { id: `view-${view.id}` }); + panel.appendChild(rootEl); + viewRoots[view.id] = rootEl; + appElement.appendChild(panel); + viewRoots[view.id].panel = panel; + }); + + const dataPanel = sf.el('div', { className: 'sf-content', style: { display: 'none' } }); + const dataRoot = sf.el('div', { id: 'sf-tables' }); + dataPanel.appendChild(dataRoot); + appElement.appendChild(dataPanel); + + const apiPanel = sf.el('div', { className: 'sf-content', style: { display: 'none' } }); + apiPanel.appendChild(sf.createApiGuide({ endpoints: buildApiGuideEndpoints(demoId, root) })); + appElement.appendChild(apiPanel); + + appElement.appendChild(sf.createFooter({ + links: [ + { label: 'SolverForge', url: 'https://www.solverforge.org' }, + { label: 'Docs', url: 'https://www.solverforge.org/docs' }, + ], + })); + + const analysisModal = sf.createModal({ title: 'Score Analysis', width: '700px' }); + + const shell = { + header, + viewRoots, + dataRoot, + dataPanel, + apiPanel, + analysisModal, + setActiveTab(tabId) { + Object.entries(viewRoots).forEach(([viewId, rootEl]) => { + rootEl.panel.style.display = viewId === tabId ? '' : 'none'; + }); + dataPanel.style.display = tabId === 'data' ? '' : 'none'; + apiPanel.style.display = tabId === 'api' ? '' : 'none'; + }, + syncLifecycleMarkers({ jobId, snapshotRevision, lifecycleState }) { + if (jobId) appElement.dataset.jobId = String(jobId); + else delete appElement.dataset.jobId; + + if (snapshotRevision != null) appElement.dataset.snapshotRevision = String(snapshotRevision); + else delete appElement.dataset.snapshotRevision; + + if (lifecycleState && lifecycleState !== 'IDLE') appElement.dataset.lifecycleState = lifecycleState; + else delete appElement.dataset.lifecycleState; + }, + openAnalysis(body) { + analysisModal.setBody(body); + analysisModal.open(); + }, + }; + + shell.setActiveTab(activeTab); + return shell; +} diff --git a/static/app/shell/app-state.mjs b/static/app/shell/app-state.mjs new file mode 100644 index 0000000000000000000000000000000000000000..85806c76affb3c417ee0a60e20ffb02ec142eed3 --- /dev/null +++ b/static/app/shell/app-state.mjs @@ -0,0 +1,7 @@ +// Minimal local state kept outside the shared SolverForge UI widgets. +export function createAppState(initialActiveTab) { + return { + currentPlan: null, + activeTab: initialActiveTab, + }; +} diff --git a/static/app/shell/config-loader.mjs b/static/app/shell/config-loader.mjs new file mode 100644 index 0000000000000000000000000000000000000000..103a2d80283a231851dc926b411165ae63c16c33 --- /dev/null +++ b/static/app/shell/config-loader.mjs @@ -0,0 +1,63 @@ +// Lightweight JSON fetch helper used during browser boot. +async function requestJson(root, path, options = undefined) { + const fetchFn = root.fetch || globalThis.fetch; + if (typeof fetchFn !== 'function') { + throw new Error(`No fetch implementation is available for ${path}`); + } + const response = await fetchFn(path, options); + if (!response || typeof response.json !== 'function') { + throw new Error(`Expected JSON response for ${path}`); + } + return response.json(); +} + +// Guards the human-authored config file before the app uses it. +function validateConfig(config) { + if (!config || typeof config.title !== 'string' || typeof config.subtitle !== 'string') { + throw new Error('sf-config.json must define title and subtitle'); + } + if (!String(config.defaultDemoId || '').trim()) { + throw new Error('sf-config.json must define defaultDemoId'); + } +} + +// Guards the generated UI model before the app tries to render from it. +function validateUiModel(uiModel) { + const valid = uiModel + && Array.isArray(uiModel.constraints) + && Array.isArray(uiModel.views) + && Array.isArray(uiModel.entities) + && Array.isArray(uiModel.facts); + if (!valid) { + throw new Error('generated/ui-model.json must define constraints, views, entities, and facts arrays'); + } + if (!uiModel.views.length) { + throw new Error('generated/ui-model.json must define at least one view'); + } +} + +// Loads every browser-boot dependency and returns the assembled boot context. +export async function loadAppConfig(root = globalThis) { + const [config, uiModel] = await Promise.all([ + requestJson(root, '/sf-config.json'), + requestJson(root, '/generated/ui-model.json'), + ]); + + validateConfig(config); + validateUiModel(uiModel); + + const backend = root.SF.createBackend({ type: 'axum', baseUrl: '' }); + const availableDemoIds = await backend.listDemoData(); + const demoId = String(config.defaultDemoId).trim(); + if (!Array.isArray(availableDemoIds) || !availableDemoIds.includes(demoId)) { + throw new Error(`Configured demo "${demoId}" is not exposed by /demo-data`); + } + + return { + config, + uiModel, + backend, + demoId, + availableDemoIds, + }; +} diff --git a/static/app/shell/data-panel.mjs b/static/app/shell/data-panel.mjs new file mode 100644 index 0000000000000000000000000000000000000000..48a89dd0b5a75f887ea944b0cbb256a7ad18659e --- /dev/null +++ b/static/app/shell/data-panel.mjs @@ -0,0 +1,22 @@ +// Converts arbitrary JSON-ish values into displayable table cells. +function stringifyCell(value) { + if (value == null) return '—'; + if (Array.isArray(value)) return value.join(', '); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +// Renders raw entity/fact tables for people who want to inspect the transport data directly. +export function renderDataTables({ sf, container, uiModel, data }) { + container.innerHTML = ''; + uiModel.entities.concat(uiModel.facts).forEach((entry) => { + const rows = data[entry.plural] || []; + if (!rows.length) return; + const columns = Object.keys(rows[0]).filter((key) => key !== 'score' && key !== 'solverStatus'); + const values = rows.map((row) => columns.map((key) => stringifyCell(row[key]))); + const section = sf.el('div', { className: 'sf-section' }); + section.appendChild(sf.el('h3', null, entry.label)); + section.appendChild(sf.createTable({ columns, rows: values })); + container.appendChild(section); + }); +} diff --git a/static/app/shell/solver-controller.mjs b/static/app/shell/solver-controller.mjs new file mode 100644 index 0000000000000000000000000000000000000000..8ebd267f36369257d7228ff8669423aaa01ae21a --- /dev/null +++ b/static/app/shell/solver-controller.mjs @@ -0,0 +1,134 @@ +// Thin adapter around the shared `SF.createSolver()` controller. +export function createSolverController({ + sf, + backend, + statusBar, + onPlan, + onAnalysis, + onMeta, + onLifecycle, + onError, +}) { + const solver = sf.createSolver({ + backend, + statusBar, + onProgress(meta) { + publishMeta(meta); + }, + onSolution(snapshot, meta) { + publishSnapshot(snapshot, meta); + }, + onPaused(snapshot, meta) { + publishSnapshot(snapshot, meta); + }, + onResumed(meta) { + publishMeta(meta); + }, + onCancelled(snapshot, meta) { + publishSnapshot(snapshot, meta); + }, + onComplete(snapshot, meta) { + publishSnapshot(snapshot, meta); + }, + onFailure(_message, meta, snapshot, analysis) { + publishSnapshot(snapshot, meta); + if (analysis) onAnalysis(analysis); + }, + onAnalysis(analysis) { + onAnalysis(analysis); + publishLifecycle(); + }, + onError(message) { + if (typeof onError === 'function') onError(message); + publishLifecycle(); + }, + }); + + return { + // Starts a new solve after cleaning up any previous terminal retained job. + async start(planProvider) { + if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED') return; + await cleanupTerminalJob(); + const plan = await planProvider(); + await solver.start(plan); + publishMeta({ + id: solver.getJobId(), + jobId: solver.getJobId(), + lifecycleState: solver.getLifecycleState(), + currentScore: null, + bestScore: null, + telemetry: null, + }); + }, + pause() { + return solver.pause().then(() => { + publishLifecycle(); + }); + }, + resume() { + return solver.resume().then(() => { + publishLifecycle(); + }); + }, + cancel() { + return solver.cancel().then(() => { + publishLifecycle(); + }); + }, + analyzeSnapshot() { + return solver.analyzeSnapshot().then((analysis) => { + onAnalysis(analysis); + publishLifecycle(); + return analysis; + }); + }, + getLifecycleState() { + return solver.getLifecycleState(); + }, + getJobId() { + return solver.getJobId(); + }, + getSnapshotRevision() { + return solver.getSnapshotRevision(); + }, + }; + + // Publishes the latest solution and lifecycle metadata back to the app shell. + function publishSnapshot(snapshot, meta) { + if (snapshot && snapshot.solution) onPlan(snapshot.solution); + publishMeta(meta); + } + + // Publishes status metadata and refreshes the shell lifecycle markers. + function publishMeta(meta) { + onMeta(meta || null); + publishLifecycle(); + } + + // Keeps HTML data attributes in sync with the underlying solver lifecycle. + function publishLifecycle() { + onLifecycle({ + jobId: solver.getJobId(), + snapshotRevision: solver.getSnapshotRevision(), + lifecycleState: solver.getLifecycleState(), + }); + } + + // Deletes old terminal jobs so "Solve" always starts from a clean retained slot. + function cleanupTerminalJob() { + const state = solver.getLifecycleState(); + if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) { + return Promise.resolve(); + } + return solver.delete() + .then(() => { + onAnalysis(null); + onMeta(null); + publishLifecycle(); + }) + .catch((error) => { + if (typeof onError === 'function') onError(error); + throw error; + }); + } +} diff --git a/static/app/views/registry.mjs b/static/app/views/registry.mjs new file mode 100644 index 0000000000000000000000000000000000000000..6ba0109956d8e43ce1cee7157e82e5eb500a9be3 --- /dev/null +++ b/static/app/views/registry.mjs @@ -0,0 +1,10 @@ +import { renderEmployeeView } from '../schedule/employee-view.mjs'; +import { renderLocationView } from '../schedule/location-view.mjs'; + +// Maps the generated UI-model view kinds to the local renderer functions. +export function createViewRegistry() { + return { + 'schedule-by-location': renderLocationView, + 'schedule-by-employee': renderEmployeeView, + }; +} diff --git a/static/generated/ui-model.json b/static/generated/ui-model.json new file mode 100644 index 0000000000000000000000000000000000000000..f1e1cdd10e749ac047b50ed6252aed914f4367a3 --- /dev/null +++ b/static/generated/ui-model.json @@ -0,0 +1,80 @@ +{ + "entities": [ + { + "name": "shift", + "plural": "shifts", + "label": "Shifts" + } + ], + "facts": [ + { + "name": "employee", + "plural": "employees", + "label": "Employees" + } + ], + "constraints": [ + { + "name": "Assigned shift", + "type": "hard" + }, + { + "name": "Required skill", + "type": "hard" + }, + { + "name": "Overlapping shift", + "type": "hard" + }, + { + "name": "At least 10 hours between 2 shifts", + "type": "hard" + }, + { + "name": "One shift per day", + "type": "hard" + }, + { + "name": "Unavailable employee", + "type": "hard" + }, + { + "name": "Undesired day for employee", + "type": "soft" + }, + { + "name": "Desired day for employee", + "type": "soft" + }, + { + "name": "Balance employee assignments", + "type": "soft" + } + ], + "views": [ + { + "id": "by-location", + "kind": "schedule-by-location", + "label": "By location", + "entity": "shift", + "entityPlural": "shifts", + "sourcePlural": "employees", + "variableField": "employeeIdx", + "allowsUnassigned": true, + "scalarHooks": {} + }, + { + "id": "by-employee", + "kind": "schedule-by-employee", + "label": "By employee", + "entity": "shift", + "entityPlural": "shifts", + "sourcePlural": "employees", + "variableField": "employeeIdx", + "allowsUnassigned": true, + "scalarHooks": {} + } + ], + "scalarGroups": [], + "conflictRepairs": [] +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..ed031225da554f5feb58716e180643828ebc6751 --- /dev/null +++ b/static/index.html @@ -0,0 +1,23 @@ + + + + + + SolverForge Hospital — SolverForge + + + + + + + +
+ + + + diff --git a/static/sf-config.json b/static/sf-config.json new file mode 100644 index 0000000000000000000000000000000000000000..0472b318af8ed4a5ecc6d0876a89e88fff642c7f --- /dev/null +++ b/static/sf-config.json @@ -0,0 +1,5 @@ +{ + "title": "SolverForge Hospital", + "subtitle": "Constraint Optimizer", + "defaultDemoId": "LARGE" +} diff --git a/tests/constraints.rs b/tests/constraints.rs new file mode 100644 index 0000000000000000000000000000000000000000..2211e87ea833ee658d47bb2d4244a47c508b431f --- /dev/null +++ b/tests/constraints.rs @@ -0,0 +1,234 @@ +use chrono::{NaiveDate, NaiveDateTime}; +use solverforge::prelude::*; +use solverforge_hospital::constraints::create_constraints; +use solverforge_hospital::domain::{Employee, Plan, Shift}; + +// These tests are intentionally tiny and direct: each one isolates one rule so +// a beginner can see how the domain model turns into score changes. + +const SCORE_SCALE: i64 = 100_000; +const UNASSIGNED_SHIFT_HARD_UNITS: i64 = 1; +const REQUIRED_SKILL_HARD_UNITS: i64 = 10; +const STRUCTURAL_FIXED_HARD_UNITS: i64 = 20; +const STRUCTURAL_MINUTE_HARD_UNITS: i64 = 20; + +// Short helper that keeps the test data readable. +fn dt(day: u32, hour: u32) -> NaiveDateTime { + NaiveDate::from_ymd_opt(2024, 1, day) + .unwrap() + .and_hms_opt(hour, 0, 0) + .unwrap() +} + +// Builds a hard score using the same scaling constants as the production constraints. +fn hard_units(units: i64) -> HardSoftDecimalScore { + HardSoftDecimalScore::of_hard_scaled(units * SCORE_SCALE) +} + +// Builds a minute-weighted hard score for overlap/rest style rules. +fn structural_hard_minutes(minutes: i64) -> HardSoftDecimalScore { + HardSoftDecimalScore::of_hard_scaled(minutes * STRUCTURAL_MINUTE_HARD_UNITS * SCORE_SCALE) +} + +#[test] +fn self_joins_count_each_pair_once() { + let employees = vec![Employee::new(0, "A").with_skill("Doctor")]; + let mut shifts = vec![ + Shift::new("1", dt(1, 8), dt(1, 16), "Ward", "Doctor"), + Shift::new("2", dt(1, 12), dt(1, 20), "Ward", "Doctor"), + ]; + shifts[0].employee_idx = Some(0); + shifts[1].employee_idx = Some(0); + + let schedule = Plan::new(employees, shifts); + let constraints = create_constraints(); + let analyses = constraints.evaluate_detailed(&schedule); + let overlap = analyses + .into_iter() + .find(|analysis| analysis.constraint_ref.name == "Overlapping shift") + .unwrap(); + + assert_eq!(overlap.matches.len(), 1); +} + +#[test] +fn unassigned_shift_is_a_hard_violation() { + let schedule = Plan::new( + vec![], + vec![Shift::new("1", dt(1, 8), dt(1, 16), "Ward", "Doctor")], + ); + + let score = create_constraints().evaluate_all(&schedule); + + assert_eq!(score.hard_score(), hard_units(-UNASSIGNED_SHIFT_HARD_UNITS)); +} + +#[test] +fn overnight_shift_penalizes_unavailable_end_date() { + let mut employee = Employee::new(0, "Night nurse") + .with_skill("Nurse") + .with_unavailable_date(NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()); + employee.finalize(); + assert_eq!(employee.unavailable_days.len(), 1); + + let mut shift = Shift::new("night-1", dt(1, 22), dt(2, 6), "Ward", "Nurse"); + shift.employee_idx = Some(0); + + assert_eq!( + { + let date = NaiveDate::from_ymd_opt(2024, 1, 2).unwrap(); + let day_start = date.and_hms_opt(0, 0, 0).unwrap(); + let day_end = date + .succ_opt() + .unwrap_or(date) + .and_hms_opt(0, 0, 0) + .unwrap(); + let start = shift.start.max(day_start); + let end = shift.end.min(day_end); + if start < end { + (end - start).num_minutes() + } else { + 0 + } + }, + 360 + ); + + let schedule = Plan::new(vec![employee], vec![shift]); + let constraints = create_constraints(); + let analyses = constraints.evaluate_detailed(&schedule); + let unavailable = analyses + .into_iter() + .find(|analysis| analysis.constraint_ref.name == "Unavailable employee") + .unwrap(); + + assert_eq!( + unavailable.score.hard_score(), + structural_hard_minutes(-360) + ); +} + +#[test] +fn overnight_shift_rewards_desired_end_date() { + let mut employee = Employee::new(0, "Night nurse") + .with_skill("Nurse") + .with_desired_date(NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()); + employee.finalize(); + assert_eq!(employee.desired_days.len(), 1); + + let mut shift = Shift::new("night-1", dt(1, 22), dt(2, 6), "Ward", "Nurse"); + shift.employee_idx = Some(0); + + assert!({ + let date = NaiveDate::from_ymd_opt(2024, 1, 2).unwrap(); + let day_start = date.and_hms_opt(0, 0, 0).unwrap(); + let day_end = date + .succ_opt() + .unwrap_or(date) + .and_hms_opt(0, 0, 0) + .unwrap(); + let start = shift.start.max(day_start); + let end = shift.end.min(day_end); + start < end && (end - start).num_minutes() > 0 + }); + + let schedule = Plan::new(vec![employee], vec![shift]); + let constraints = create_constraints(); + let analyses = constraints.evaluate_detailed(&schedule); + let desired = analyses + .into_iter() + .find(|analysis| analysis.constraint_ref.name == "Desired day for employee") + .unwrap(); + + assert_eq!(desired.score.soft_score(), HardSoftDecimalScore::of_soft(1)); +} + +#[test] +fn employee_joined_analysis_reports_required_skill_match() { + let employees = vec![Employee::new(0, "Taylor").with_skill("Nurse")]; + let mut shift = Shift::new("1", dt(1, 8), dt(1, 16), "Ward", "Doctor"); + shift.employee_idx = Some(0); + + let schedule = Plan::new(employees, vec![shift]); + let constraints = create_constraints(); + let analyses = constraints.evaluate_detailed(&schedule); + let required_skill = analyses + .into_iter() + .find(|analysis| analysis.constraint_ref.name == "Required skill") + .unwrap(); + + assert_eq!(required_skill.matches.len(), 1); + assert_eq!( + required_skill.score.hard_score(), + hard_units(-REQUIRED_SKILL_HARD_UNITS) + ); +} + +#[test] +fn overnight_and_next_day_shift_violate_one_shift_per_day() { + let employees = vec![Employee::new(0, "A").with_skill("Doctor")]; + let mut overnight = Shift::new("night", dt(1, 22), dt(2, 6), "Ward", "Doctor"); + overnight.employee_idx = Some(0); + let mut evening = Shift::new("late", dt(2, 18), dt(2, 22), "Ward", "Doctor"); + evening.employee_idx = Some(0); + + let schedule = Plan::new(employees, vec![overnight, evening]); + let constraints = create_constraints(); + let analyses = constraints.evaluate_detailed(&schedule); + let one_per_day = analyses + .into_iter() + .find(|analysis| analysis.constraint_ref.name == "One shift per day") + .unwrap(); + + assert_eq!(one_per_day.matches.len(), 1); + assert_eq!( + one_per_day.score.hard_score(), + hard_units(-STRUCTURAL_FIXED_HARD_UNITS) + ); +} + +#[test] +fn required_skill_is_worse_than_unassigned() { + let unassigned = create_constraints().evaluate_all(&Plan::new( + vec![], + vec![Shift::new("missing", dt(1, 8), dt(1, 16), "Ward", "Doctor")], + )); + + let employees = vec![Employee::new(0, "Taylor").with_skill("Nurse")]; + let mut shift = Shift::new("wrong-skill", dt(1, 8), dt(1, 16), "Ward", "Doctor"); + shift.employee_idx = Some(0); + let wrong_skill = create_constraints().evaluate_all(&Plan::new(employees, vec![shift])); + + assert!(wrong_skill < unassigned); + assert_eq!( + unassigned.hard_score(), + hard_units(-UNASSIGNED_SHIFT_HARD_UNITS) + ); + assert_eq!( + wrong_skill.hard_score(), + hard_units(-REQUIRED_SKILL_HARD_UNITS) + ); +} + +#[test] +fn one_shift_per_day_does_not_cross_employee_boundaries() { + let employees = vec![ + Employee::new(0, "A").with_skill("Doctor"), + Employee::new(1, "B").with_skill("Doctor"), + ]; + let mut overnight = Shift::new("night", dt(1, 22), dt(2, 6), "Ward", "Doctor"); + overnight.employee_idx = Some(0); + let mut evening = Shift::new("late", dt(2, 18), dt(2, 22), "Ward", "Doctor"); + evening.employee_idx = Some(1); + + let schedule = Plan::new(employees, vec![overnight, evening]); + let constraints = create_constraints(); + let analyses = constraints.evaluate_detailed(&schedule); + let one_per_day = analyses + .into_iter() + .find(|analysis| analysis.constraint_ref.name == "One shift per day") + .unwrap(); + + assert!(one_per_day.matches.is_empty()); + assert_eq!(one_per_day.score, HardSoftDecimalScore::ZERO); +} diff --git a/tests/e2e/app.spec.js b/tests/e2e/app.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8b1602ba436c12544e32d79c168c586179799fad --- /dev/null +++ b/tests/e2e/app.spec.js @@ -0,0 +1,79 @@ +const { test, expect } = require('playwright/test'); + +function collectBrowserErrors(page) { + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + return errors; +} + +test('boots the real hospital app and serves required browser assets', async ({ page, request }) => { + const errors = collectBrowserErrors(page); + + await expect(request.get('/health')).resolves.toBeOK(); + await expect(request.get('/info')).resolves.toBeOK(); + await expect(request.get('/sf/sf.js')).resolves.toBeOK(); + await expect(request.get('/sf/sf.css')).resolves.toBeOK(); + await expect(request.get('/app/main.mjs')).resolves.toBeOK(); + await expect(request.get('/generated/ui-model.json')).resolves.toBeOK(); + await expect(request.get('/sf-config.json')).resolves.toBeOK(); + await expect((await request.get('/demo-data')).json()).resolves.toEqual(['LARGE']); + + await page.goto('/'); + await expect(page).toHaveTitle('SolverForge Hospital — SolverForge'); + await expect(page.getByText('SolverForge Hospital')).toBeVisible(); + await expect(page.getByText('Constraint Optimizer')).toBeVisible(); + await expect(page.locator('#sfStatusText')).toHaveText('Ready'); + await expect(page.locator('.sf-constraint-dot')).toHaveCount(9); + + for (const tab of ['By location', 'By employee', 'Data', 'REST API']) { + await expect(page.getByRole('tab', { name: tab })).toBeVisible(); + } + + await expect(page.getByText('Location schedule')).toBeVisible(); + await expect(page.locator('.sf-rail-timeline')).toHaveCount(2); + await expect(page.locator('.sf-rail-timeline-row').first()).toBeVisible(); + await expect(page.locator('.sf-rail-timeline-item').first()).toBeVisible(); + + expect(errors).toEqual([]); +}); + +test('renders hospital-specific views and the visible REST API guide', async ({ page }) => { + const errors = collectBrowserErrors(page); + + await page.goto('/'); + await page.getByRole('tab', { name: 'By employee' }).click(); + await expect(page.getByText('Employee schedule')).toBeVisible(); + await expect(page.locator('#view-by-employee .sf-rail-timeline-item').first()).toBeVisible(); + + await page.getByRole('tab', { name: 'Data' }).click(); + await expect(page.getByRole('heading', { name: 'Shifts' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Employees' })).toBeVisible(); + await expect(page.getByText('CAREHUB')).toBeVisible(); + await expect(page.getByText('EMPLOYEEIDX')).toBeVisible(); + + await page.getByRole('tab', { name: 'REST API' }).click(); + await expect(page.getByRole('heading', { name: 'GET /demo-data/LARGE' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'GET /jobs/{id}/events' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'GET /jobs/{id}/analysis?snapshot_revision={n}' })).toBeVisible(); + + expect(errors).toEqual([]); +}); + +test('starts a retained solve and returns control to the user', async ({ page }) => { + const errors = collectBrowserErrors(page); + + await page.goto('/'); + await page.locator('button').filter({ hasText: 'Solve' }).first().click(); + + await expect(page.locator('#sf-app')).toHaveAttribute('data-job-id', /.+/, { timeout: 10_000 }); + const stopButton = page.locator('button').filter({ hasText: 'Stop' }).first(); + if (await stopButton.isVisible()) { + await stopButton.click(); + } + await expect(page.locator('button').filter({ hasText: 'Solve' }).first()).toBeVisible({ timeout: 15_000 }); + + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 0000000000000000000000000000000000000000..75c5bcd6c4ef765a8f9e8be0900973f22d55e3d7 --- /dev/null +++ b/tests/e2e/playwright.config.js @@ -0,0 +1,31 @@ +const path = require('node:path'); + +const rootDir = path.resolve(__dirname, '../..'); +const port = Number(process.env.PLAYWRIGHT_PORT || 17961); +const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${port}`; + +module.exports = { + testDir: '.', + testMatch: '*.spec.js', + workers: 1, + timeout: 45_000, + reporter: [['list']], + use: { + baseURL, + browserName: 'chromium', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + viewport: { width: 1440, height: 1000 }, + launchOptions: { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || '/usr/bin/chromium', + args: ['--no-sandbox'], + }, + }, + webServer: { + command: `PORT=${port} ${path.join(rootDir, 'target/release/solverforge-hospital')}`, + cwd: rootDir, + url: `${baseURL}/health`, + timeout: 20_000, + reuseExistingServer: false, + }, +}; diff --git a/tests/frontend/analysis-modal.test.js b/tests/frontend/analysis-modal.test.js new file mode 100644 index 0000000000000000000000000000000000000000..7765cf22f0c54408fe2964cdc653cac8e833b569 --- /dev/null +++ b/tests/frontend/analysis-modal.test.js @@ -0,0 +1,34 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { withBrowserEnv } = require('./support/load-browser-modules'); + +// Frontend tests double as documentation for the intended UI contract. +test('analysis modal body uses DOM content and preserves text escaping', async () => { + await withBrowserEnv({}, async ({ document, importModule }) => { + const { buildAnalysisBody } = await importModule('static/app/schedule/analysis-modal.mjs'); + + const body = buildAnalysisBody(document, { + analysis: { + score: '0hard/0soft', + constraints: [ + { + name: '', + score: '0hard/0soft', + matchCount: 0, + }, + ], + }, + }, [ + { name: '', type: 'hard' }, + ]); + + const table = body.querySelector('table'); + assert.ok(table, 'expected a rendered table node'); + assert.equal(table.className, 'sf-table'); + assert.equal(body.querySelector('script'), null); + assert.match(body.textContent, /