diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..f42509d25936192f1b1fede21ebad1481aea6a9f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +target/ +.git/ +.osm_cache/ +test-results/ +playwright-report/ +*.rs.bk +/app-session-*.js +/comment-preload.js +/main-*.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..cc78802311ae76f774f851bd1beb92f6657c9385 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,36 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text +docs/screenshot.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4a1281b22fe5c2e87b3cb65d7c793229b34af342 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target +.osm_cache/ +**/*.rs.bk +test-results/ +playwright-report/ +/app-session-*.js +/comment-preload.js +/main-*.js 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..39b0b3478ec08eed2b91c8b2a1ea4d20c78c5868 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# Repository Guidelines + +## Project Structure And Naming + +`solverforge-fsr` is a Rust 1.95 SolverForge field-service routing app with an +Axum server and static browser workspace. The app package version is `1.0.1`, +and the release binary is `solverforge_fsr`. + +- `src/domain/mod.rs` owns the `solverforge::planning_model!` manifest. +- `src/domain/field_service_plan.rs` owns the `FieldServicePlan` solution. +- `src/domain/location.rs`, `service_visit.rs`, and `travel_leg.rs` own the + problem facts. +- `src/domain/technician_route.rs` owns the planning entity and its `visits` + list variable. +- `src/constraints/` owns one score rule per file plus route metric helpers. +- `src/data/data_seed.rs` owns deterministic Bergamo demo generation and road + matrix preparation. +- `src/api/` owns REST, DTO, route geometry, and SSE surfaces. +- `src/solver/` owns retained-job runtime orchestration. +- `static/` owns the browser workspace, split by responsibility + (`app-route-state.js`, `app-render-routes.js`, etc.). +- `Dockerfile`, `Makefile`, `solver.toml`, and `solverforge.app.toml` define + the deployment and runtime contract. + +Keep handwritten source, docs, and deployment files under 300 lines; split by +module or responsibility when a file approaches that size. + +## Build, Test, and Development Commands + +- `make doctor` checks local `cargo`, `rustc`, `node`, and `docker` readiness. +- `make run` runs the debug server on `PORT` (default `7860`). +- `make build-release` builds `solverforge_fsr` in release mode. +- `make test` runs Rust tests plus frontend JavaScript syntax checks. +- `make lint` runs `cargo fmt --check`, clippy with warnings denied, and JS syntax checks. +- `make ci-local` runs the full Hugging Face Space validation path, including Docker image build. +- `make space-run` builds and runs the Docker Space image locally. + +## Coding Style & Naming Conventions + +Use idiomatic Rust 2021 with `cargo fmt` formatting and clippy under +`-D warnings`. Rust modules and files use `snake_case`; types use `PascalCase`; +functions, fields, and variables use `snake_case`. Keep API DTOs explicit and +snapshot-scoped. Frontend files should stay plain JavaScript modules with clear +ownership boundaries rather than large shared scripts. + +## Testing Guidelines + +Place Rust unit tests near the code they cover, using descriptive names such as +`reports_unreachable_route_segments`. Run `make test` before handing off normal +changes and `make ci-local` before deployment, dependency, Docker, or Space +changes. Frontend validation is currently syntax-level via `node --check` over +`static/*.js`. + +## Documentation And Commenting Policy + +Assume a reader who is new to Rust and new to planning optimization. + +- Keep `README.md`, `WIREFRAME.md`, this file, `solver.toml`, + `solverforge.app.toml`, `static/sf-config.json`, and the visible browser API + guide aligned. +- Keep `docs/screenshot.png` current whenever the visible browser shell changes. +- Add module or function comments where code coordinates SolverForge concepts: + facts, planning entities, variables, retained jobs, road matrices, route + geometry, or score math. +- Explain domain meaning and solver consequences. Do not keep scaffold + placeholders, future-tense planning prose, or comments that merely restate + syntax. +- When docs mention versions, counts, routes, demo IDs, solver policy, or + validation expectations, verify those facts against current code in the same + patch. + +## Commit & Pull Request Guidelines + +History uses conventional commits such as `feat(fsr): ...`, `fix(ui): ...`, +and `chore: ...`. Keep each commit focused on one revertable +intent and include a full body when the change spans behavior, deployment, or +dependencies. PRs should describe the user-visible effect, linked issue or +review comment, validation commands run, and include screenshots for visible UI +changes. + +## Security & Configuration Tips + +Do not commit credentials, local Hugging Face tokens, generated desktop bundles, +or build output. Keep Docker/Space builds registry-backed through the declared +crates.io dependency line unless the build context explicitly vendors local +crates. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..ff9ae1276738ebd0b1c3a7249af37dda02f611b8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this use case are documented in this file. + +## 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.1 (2026-05-14) + +### Features + +* **fsr:** publish the SolverForge field-service routing use case in the bundle. + +### Maintenance + +* **release:** align the bundled app with SolverForge 0.13.1, solverforge-core 0.13.1, solverforge-ui 0.6.5, and solverforge-maps 2.1.4. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..fb23b32c8b914e2754cddd57d4306fe442934ae7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2672 @@ +# 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 = "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 = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[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.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[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 0.10.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[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 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[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 = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "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 = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[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 = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[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 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +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 = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "solverforge" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86fef307d129ed9d674f29e2f37be912a9b901a4fe9f43348f1044879ef7094" +dependencies = [ + "solverforge-config", + "solverforge-console", + "solverforge-core", + "solverforge-cvrp", + "solverforge-macros", + "solverforge-scoring", + "solverforge-solver", + "tokio", +] + +[[package]] +name = "solverforge-config" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434a6c8f9f308d20c7d364324de9d0dbb27d5fe03a7e8f1cead0c2574ebe947e" +dependencies = [ + "serde", + "serde_yaml", + "solverforge-core", + "thiserror", + "toml", +] + +[[package]] +name = "solverforge-console" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456459eb2efbb3d57a6b5234c7d29a2aab84c8d14d5a09807ab0ac94bf9a786" +dependencies = [ + "num-format", + "owo-colors", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "solverforge-core" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc29944f60ac425f53b4605ea2f0fa5fa499549139df7a889aa5fdc47bcc066" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "solverforge-cvrp" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cbe85229dd46e845f95f1f4d2ac993145680b02e044acdce220ee4ddecbd36" +dependencies = [ + "solverforge-solver", +] + +[[package]] +name = "solverforge-fsr" +version = "2.0.0" +dependencies = [ + "axum", + "parking_lot", + "serde", + "serde_json", + "solverforge", + "solverforge-core", + "solverforge-maps", + "solverforge-ui", + "tokio", + "tokio-stream", + "tower", + "tower-http", + "uuid", +] + +[[package]] +name = "solverforge-macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f830c19a7d9c7fe10911cf597b0b98c6d63ef2ec70330ac6e0b2746e8db8d98" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "solverforge-maps" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31f816d221238ba3ade93315e6a605486b53d9ec26527477d165b507ece6a88" +dependencies = [ + "rayon", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "utoipa", +] + +[[package]] +name = "solverforge-scoring" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efaede461a62baa20a0f65887bda5760158a125a831ecc73c50d713ac9d6ce7" +dependencies = [ + "solverforge-core", + "thiserror", +] + +[[package]] +name = "solverforge-solver" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0156969a9e5d965d7fda36a8581646115e5594a2d50d1036e101dcb001167530" +dependencies = [ + "rand 0.10.1", + "rand_chacha 0.10.0", + "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 = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[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 0.61.2", +] + +[[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-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +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", + "tower-layer", + "tower-service", + "url", +] + +[[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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[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 = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +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 = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + +[[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 = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +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..897b94ea086ae05614aec3b8a6ea066addb5766a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "solverforge-fsr" +version = "2.0.0" +edition = "2021" +rust-version = "1.95" +description = "Constraint optimizer built with SolverForge" + +[[bin]] +name = "solverforge_fsr" +path = "src/main.rs" + +[dependencies] +solverforge = { version = "0.13.1", features = ["serde", "console", "verbose-logging"] } +solverforge-core = { version = "0.13.1" } +solverforge-ui = { version = "0.6.5" } +solverforge-maps = { version = "2.1.4" } +# Web server +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" + +# Serialization +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" + +# Utilities +uuid = { version = "1.23.1", features = ["v4", "serde"] } +parking_lot = "0.12.5" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4aadc8cc55e228c4ce14ae7a6b9f7b91a34c321e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Multi-stage build for solverforge-fsr. +# +# The app is intended to build from registry dependency declarations, so the +# repository root is the complete Docker build context: +# docker build -f Dockerfile -t solverforge-fsr . + +FROM rust:1.95-alpine AS builder + +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 +COPY solverforge.app.toml ./solverforge.app.toml + +RUN cargo build --release --target x86_64-unknown-linux-musl + +FROM alpine:latest + +RUN apk add --no-cache ca-certificates + +WORKDIR /app + +COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/solverforge_fsr ./solverforge_fsr +COPY --from=builder /build/static/ ./static/ +COPY --from=builder /build/solver.toml ./solver.toml +COPY --from=builder /build/solverforge.app.toml ./solverforge.app.toml + +ENV PORT=7860 + +EXPOSE 7860 + +CMD ["./solverforge_fsr"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..a8f6874b7e5c2252eb8bca1d5c9a8d0cd73d61eb --- /dev/null +++ b/Makefile @@ -0,0 +1,207 @@ +# SolverForge FSR Makefile +# Rust + frontend syntax + Space-oriented local build system. + +SHELL := /bin/sh +.SHELLFLAGS := -eu -c +unexport BASH_FUNC_mc%% + +GREEN := \033[92m +CYAN := \033[96m +YELLOW := \033[93m +RED := \033[91m +GRAY := \033[90m +BOLD := \033[1m +RESET := \033[0m + +CHECK := OK +CROSS := FAIL +ARROW := => +PROGRESS := .. + +APP_NAME := solverforge_fsr +PACKAGE_NAME := solverforge-fsr +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 ?= $(PACKAGE_NAME) +DOCKER_CONTEXT ?= . +DOCKERFILE_PATH := Dockerfile +PLAYWRIGHT ?= ../node_modules/.bin/playwright + +.PHONY: help doctor build build-release run run-release test test-rust \ + test-frontend-syntax test-e2e 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_GOAL := help + +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: + @printf "$(CYAN)$(BOLD)Environment Check$(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 "$(YELLOW)! node not found; frontend syntax validation will be unavailable$(RESET)\n"; \ + 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 + +build: + @printf "$(ARROW) Building $(PACKAGE_NAME)...\n" + @cargo build --bin $(APP_NAME) + +build-release: + @printf "$(ARROW) Building release binary...\n" + @cargo build --release --bin $(APP_NAME) + +run: + @printf "$(ARROW) Running $(PACKAGE_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: test-rust test-frontend-syntax test-e2e + @printf "\n$(GREEN)$(BOLD)$(CHECK) Standard validation passed$(RESET)\n\n" + +test-rust: + @printf "$(PROGRESS) Running cargo test --quiet...\n" + @cargo test --quiet + +test-frontend-syntax: require-node + @printf "$(PROGRESS) Checking frontend module syntax...\n" + @find static -name '*.js' -print0 | xargs -0 -n1 node --check + +test-e2e: build-release require-node + @printf "$(PROGRESS) Running Playwright browser tests...\n" + @$(PLAYWRIGHT) test --config tests/e2e/playwright.config.js + +fmt: + @printf "$(PROGRESS) Formatting Rust code...\n" + @cargo fmt + +fmt-check: + @printf "$(PROGRESS) Checking Rust formatting...\n" + @cargo fmt --check + +clippy: + @printf "$(PROGRESS) Running clippy...\n" + @cargo clippy --all-targets -- -D warnings + +lint: fmt-check clippy test-frontend-syntax + @printf "\n$(GREEN)$(BOLD)$(CHECK) Lint checks passed$(RESET)\n\n" + +check: lint test + +docker-build: require-docker + @printf "$(PROGRESS) Building Docker image $(DOCKER_IMAGE)...\n" + @docker build -f "$(DOCKERFILE_PATH)" -t "$(DOCKER_IMAGE)" "$(DOCKER_CONTEXT)" + +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-local: + @printf "$(CYAN)$(BOLD)Local Space Validation Pipeline$(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)$(CHECK) LOCAL SPACE VALIDATION PASSED$(RESET)\n\n" + +pre-release: ci-local + @printf "$(GREEN)$(BOLD)$(CHECK) Ready for Hugging Face 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" + +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 + +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) + @PORT=$(PORT) cargo watch -x "run --bin $(APP_NAME)" + +help: + @/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 locally on port $(PORT)" + @/bin/echo -e " $(GREEN)make run-release$(RESET) - Run the release build on port $(PORT)" + @/bin/echo -e "" + @/bin/echo -e "$(CYAN)$(BOLD)Tests & Validation:$(RESET)" + @/bin/echo -e " $(GREEN)make test$(RESET) - Run Rust, frontend syntax, and Playwright checks" + @/bin/echo -e " $(GREEN)make test-e2e$(RESET) - Run Playwright browser tests" + @/bin/echo -e " $(GREEN)make lint$(RESET) - Run fmt-check, clippy, and frontend syntax checks" + @/bin/echo -e " $(GREEN)make ci-local$(RESET) - Run local Space validation pipeline" + @/bin/echo -e " $(GREEN)make release-ci$(RESET) - Run the tag-publish CI gate for this app" + @/bin/echo -e " $(GREEN)make pre-release$(RESET) - Run all local Space readiness checks" + @/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 deployment" + @/bin/echo -e " $(GREEN)make space-run$(RESET) - Build and run that image locally on port $(PORT)" + @/bin/echo -e " $(GREEN)make docker-build$(RESET) - Build the Docker image directly" + @/bin/echo -e " $(GREEN)make docker-run$(RESET) - Run the Docker image directly" + @/bin/echo -e "" + @/bin/echo -e "$(CYAN)$(BOLD)Other:$(RESET)" + @/bin/echo -e " $(GREEN)make doctor$(RESET) - Check local cargo/rustc/node readiness" + @/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 "" + @/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)" diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e668410beb6170b4bd657b706ad97ee034316971 --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +--- +title: SolverForge Field Service Routing +emoji: ๐Ÿงฐ +colorFrom: indigo +colorTo: blue +sdk: docker +app_port: 7860 +pinned: false +license: apache-2.0 +short_description: SolverForge field-service routing example +--- + +# SolverForge FSR + +![SolverForge FSR screenshot](docs/screenshot.png) + +`solverforge-fsr` is a SolverForge field-service routing app with retained +jobs, technician schedules, road-network geometry, and a browser map workspace. + +It answers one concrete question: + +"Given technicians, service visits, skills, parts, shifts, territories, and +road-network travel, which technician should serve each visit and in what +order?" + +## 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, routing, and UI. +- `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-fsr`; version is declared in `Cargo.toml` +- Release binary: `solverforge_fsr` +- Rust: `1.95` +- SolverForge runtime: `solverforge` `0.13.1` +- SolverForge core helpers: `solverforge-core` `0.13.1` +- Browser UI assets: `solverforge-ui` `0.6.5` +- Routing engine: `solverforge-maps` `2.1.4` +- Scaffold metadata: `solverforge-cli` `2.0.4` in `solverforge.app.toml` + +The app serves registry-backed Rust dependencies, local static browser modules, +and Axum API routes from one process. + +## Model Concepts + +- `Location` is a problem fact: a depot or customer coordinate. +- `ServiceVisit` is a problem fact: a customer job the solver must place in a + route. +- `TravelLeg` is a problem fact: precomputed duration, distance, and + reachability between two locations. +- `TechnicianRoute` is the planning entity: one route owned by one technician. +- `TechnicianRoute.visits` is the list planning variable: the ordered visit + sequence SolverForge changes. +- `FieldServicePlan` is the planning solution with the current `HardSoftScore`. + +The app ships one deterministic `STANDARD` Bergamo dataset with two depots, six +technicians, 24 customer locations, and 48 service visits. + +## Constraints + +Hard constraints: + +- Every service visit is assigned. +- Every route leg is reachable. +- The assigned technician has the required skills. +- The assigned technician carries the required parts. +- Visits fit their time windows. +- Routes fit technician shift capacity. + +Soft constraints: + +- Total travel time is minimized. +- Workload is balanced across technicians. +- Territory affinity is preferred. +- Higher-priority visits have less slack. + +## 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` +- `GET /jobs/{id}/routes` +- `POST /jobs/{id}/pause` +- `POST /jobs/{id}/resume` +- `POST /jobs/{id}/cancel` +- `GET /jobs/{id}/events` + +`snapshot_revision={n}` is optional for snapshots, analysis, and route +geometry. Route geometry reports unreachable, snap-failed, and no-path legs as +segment statuses so one failed road leg does not hide the rest of the route. + +## Solver Policy + +`solver.toml` is embedded by `FieldServicePlan` and is the runtime source of +truth. + +- `list_round_robin` creates the first visit distribution. +- Local search combines list change, list swap, sublist change, sublist swap, + and reverse moves over `TechnicianRoute.visits`. +- `hill_climbing` with `first_best_score_improving` keeps this tutorial easy to + reason about. +- Solving stops after 60 seconds. + +Road-network routing is prepared from the deterministic Bergamo coordinates and +stored as `TravelLeg` facts before solving. + +## Validation + +Standard validation: + +```sh +make test +``` + +Full local validation: + +```sh +make ci-local +``` + +`make test` runs Rust tests, JavaScript syntax checks, and Playwright browser +tests. `make ci-local` adds formatting, clippy, release build, and Docker image +build. + +## 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/mod.rs` + The `planning_model!` manifest and public domain exports. +2. `src/domain/field_service_plan.rs` + The solution type, fact collections, route entities, and score. +3. `src/domain/location.rs`, `src/domain/service_visit.rs`, and + `src/domain/travel_leg.rs` + The problem facts the solver reads. +4. `src/domain/technician_route.rs` + The planning entity and list variable SolverForge mutates. +5. `src/data/data_seed.rs` + Demo ID, Bergamo data assembly, routing preparation, and cache policy. +6. `src/constraints/mod.rs` and `src/constraints/route_metrics.rs` + The score model and shared route-measurement math. +7. `src/constraints/*.rs` + One business scoring rule per file. +8. `src/solver/service.rs` + Retained-job orchestration over `SolverManager`. +9. `src/api/routes.rs`, `src/api/dto.rs`, `src/api/route_geometry.rs`, and + `src/api/sse.rs` + HTTP routes, transport DTOs, route geometry, and live-event streaming. +10. `static/app.js` and `static/app-*.js` + Browser lifecycle, dataset loading, route rendering, maps, tables, and API + guide. + +## Project Shape + +- `src/domain/` + Planning model, domain types, and route entities. +- `src/constraints/` + Incremental SolverForge scoring rules and route metric helpers. +- `src/data/` + Deterministic Bergamo demo data and road-network preparation. +- `src/solver/` + Retained-job facade and runtime event payload formatting. +- `src/api/` + Axum routes, DTOs, route geometry, and SSE endpoint. +- `static/` + Browser workspace built on stock `solverforge-ui` assets. +- `tests/e2e/` + Playwright browser tests for the served app. diff --git a/WIREFRAME.md b/WIREFRAME.md new file mode 100644 index 0000000000000000000000000000000000000000..1a988f5f6f2ca4a66dc046a75c070631d42d0356 --- /dev/null +++ b/WIREFRAME.md @@ -0,0 +1,192 @@ +# solverforge-fsr WIREFRAME + +This file is the architectural map for the field-service routing example. + +`README.md` explains how to run and use the app. This document explains how the +pieces fit together and where each responsibility lives. + +## Documentation Roles + +- `README.md` + Quick start, dependency shape, API list, and user-facing orientation. +- `WIREFRAME.md` + Architecture, execution flow, and file-map walkthrough. +- `AGENTS.md` + Repo-specific contribution, validation, and documentation rules. +- `Makefile` + Local development, validation, and Space/Docker command surface. +- `Dockerfile` + Hugging Face Docker Space image definition. + +## What This Repo Is Teaching + +This repo is a complete `solverforge-fsr` `1.0.1` list-variable SolverForge app +for field-service routing in Bergamo. + +It shows how to combine: + +- a `FieldServicePlan` solution with a list planning variable +- route-level hard and soft score rules +- precomputed travel-leg facts and `solverforge-maps` road-network geometry +- retained jobs with snapshots, analysis, cancel, pause, resume, and SSE +- a browser map workspace built on stock `solverforge-ui` assets + +## SolverForge Concepts In Plain Language + +- `Location` + Input place data. Depots and customer sites are indexed so routes can refer to + them cheaply. +- `ServiceVisit` + Input job data. The solver places visit indexes into technician routes. +- `TravelLeg` + Input travel data. Each leg records duration, distance, and whether the road + graph can connect the two locations. +- `TechnicianRoute` + Planning entity. Each technician owns one ordered `visits` list. +- `FieldServicePlan` + Planning solution. It holds facts, route entities, and the current score. +- hard score + Missing assignments, unreachable legs, missing skills or parts, late visits, + and route overtime. +- soft score + Travel cost, workload balance, territory fit, and priority slack. +- retained job + A solve that lives in memory so the UI can stream events, fetch snapshots, + pause/resume, cancel, analyze, and delete terminal jobs. + +## Runtime Flow + +1. The browser loads `static/index.html`. +2. `static/app.js` loads `static/sf-config.json` and + `static/generated/ui-model.json`. +3. The app fetches `/demo-data/STANDARD`. +4. The backend returns a `FieldServicePlan` with seed travel legs. +5. The browser renders route cards, tables, timeline, map shell, and the visible + REST API guide. +6. When the user clicks Solve, the browser posts the current plan to + `POST /jobs`. +7. `src/api/routes.rs` deserializes the `PlanDto` and calls + `prepare_routing()`. +8. `prepare_routing()` loads or fetches the Bergamo road network, computes the + full travel matrix, and replaces seed legs with road-network legs. +9. `SolverService` starts a retained solve through + `SolverManager`. +10. Solver events are converted by `src/solver/event_payload.rs` into + UI-facing JSON. +11. The browser consumes `/jobs/{id}/events` and fetches snapshots, analysis, + and route geometry for exact snapshot revisions. +12. `src/api/route_geometry.rs` builds map segments, preserving non-routed + statuses so one unreachable leg does not hide the rest of a route. + +## File Map + +```text +. +โ”œโ”€โ”€ Cargo.toml +โ”‚ Rust 1.95 crate metadata for app version 1.0.1 and registry dependency +โ”‚ requests. +โ”œโ”€โ”€ solver.toml +โ”‚ Embedded search policy for list construction and local search. +โ”œโ”€โ”€ solverforge.app.toml +โ”‚ App metadata, demo IDs, model facts/entities, registry dependency sources, +โ”‚ and the `solverforge 0.13.1` runtime target. +โ”œโ”€โ”€ Makefile +โ”‚ Local build, validation, and Space/Docker commands. +โ”œโ”€โ”€ Dockerfile +โ”‚ Multi-stage Rust 1.95 Docker image for Hugging Face Spaces. +โ”œโ”€โ”€ README.md +โ”‚ Run guide, dependency shape, API list, and learning path. +โ”œโ”€โ”€ AGENTS.md +โ”‚ Repo-specific rules for future edits. +โ”œโ”€โ”€ WIREFRAME.md +โ”‚ This architectural walkthrough. +โ”œโ”€โ”€ docs/screenshot.png +โ”‚ Current browser screenshot used by the README. +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ domain/ +โ”‚ โ”‚ `planning_model!` manifest, facts, route entity, and solution. +โ”‚ โ”œโ”€โ”€ constraints/ +โ”‚ โ”‚ Route metric helpers and one score rule per file. +โ”‚ โ”œโ”€โ”€ data/ +โ”‚ โ”‚ Deterministic Bergamo seeds, demo entrypoints, and OSM matrix loading. +โ”‚ โ”œโ”€โ”€ solver/ +โ”‚ โ”‚ Retained-job service and runtime event payload formatting. +โ”‚ โ””โ”€โ”€ api/ +โ”‚ Axum routes, DTOs, route geometry, and SSE endpoint. +โ””โ”€โ”€ static/ + โ”œโ”€โ”€ index.html + โ”œโ”€โ”€ sf-config.json + โ”œโ”€โ”€ generated/ui-model.json + โ””โ”€โ”€ app*.js + Browser controller, map rendering, route state, layout, and tables. +``` + +## Domain And Route Metrics + +`src/domain/field_service_plan.rs` owns the public solution shape. It keeps the +SolverForge model explicit: facts are read-only inputs, while +`TechnicianRoute.visits` is the one mutable list variable. + +Route-specific scoring math lives in `src/constraints/route_metrics.rs`. That +module walks a route from depot to visits to depot, advances a service clock, +and records reusable counters for the individual constraints. + +`src/api/route_geometry.rs` is separate because map drawing has a different +job from scoring. Scoring consumes matrix facts already on the plan; geometry +loads the road graph to draw visible polylines for a retained snapshot. + +## Demo Data + +`src/data/data_seed.rs` exposes one demo ID: + +- `STANDARD` + +The generator is deterministic. It builds two depots, 24 customer locations, 48 +visits, six technicians, and seed self-leg travel facts. Full road-network +travel facts are prepared when a job is created. + +## API And Retained Runtime + +The REST API handles job control and snapshot reads: + +- `/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}/routes` returns route geometry for a snapshot. +- `/jobs/{id}/events` streams typed lifecycle events. + +## Frontend Layout + +`static/app.js` is the controller. It owns current plan state, retained job +state, route focus, event handlers, and analysis modal wiring. + +Supporting modules split the UI by responsibility: + +- `static/app-dataset.js` + Demo catalog and plan loading. +- `static/app-layout.js` + Page shell and stock SolverForge UI component composition. +- `static/app-route-state.js` + Snapshot identity and route geometry cache coordination. +- `static/app-render*.js` + Summary cards, route cards, maps, timeline, tables, and API guide. +- `static/app-utils.js` + Plan cloning, labels, formatting, and color helpers. + +## Validation Surfaces + +Use the Makefile as the repo-local workflow: + +- `make fmt-check` +- `make clippy` +- `make build-release` +- `make test` +- `make test-e2e` +- `make space-build` +- `make ci-local` +- `make pre-release` + +`make ci-local` includes the Docker image build used by the Hugging Face Space. +The Playwright command uses the publication bundle's root Node dev dependency; +runtime UI assets are served from the declared `solverforge-ui` Cargo crate. diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..4435b146ad0f525aa3828472ce447c77fc9e6b86 --- /dev/null +++ b/docs/screenshot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7bc17e1f1984c781bb6da623283f703dc782aa7fea7bd6d074ad606b11c7114 +size 93890 diff --git a/solver.toml b/solver.toml new file mode 100644 index 0000000000000000000000000000000000000000..0c33f98d904cc572e90db3200d02ac4b94751eea --- /dev/null +++ b/solver.toml @@ -0,0 +1,48 @@ +[[phases]] +type = "construction_heuristic" +construction_heuristic_type = "list_round_robin" + +[[phases]] +type = "local_search" + +[phases.acceptor] +type = "hill_climbing" + +[phases.forager] +type = "first_best_score_improving" + +[phases.move_selector] +type = "union_move_selector" +selection_order = "round_robin" + +[[phases.move_selector.selectors]] +type = "list_change_move_selector" +entity_class = "TechnicianRoute" +variable_name = "visits" + +[[phases.move_selector.selectors]] +type = "list_swap_move_selector" +entity_class = "TechnicianRoute" +variable_name = "visits" + +[[phases.move_selector.selectors]] +type = "sublist_change_move_selector" +min_sublist_size = 1 +max_sublist_size = 3 +entity_class = "TechnicianRoute" +variable_name = "visits" + +[[phases.move_selector.selectors]] +type = "sublist_swap_move_selector" +min_sublist_size = 1 +max_sublist_size = 3 +entity_class = "TechnicianRoute" +variable_name = "visits" + +[[phases.move_selector.selectors]] +type = "list_reverse_move_selector" +entity_class = "TechnicianRoute" +variable_name = "visits" + +[termination] +seconds_spent_limit = 60 diff --git a/solverforge.app.toml b/solverforge.app.toml new file mode 100644 index 0000000000000000000000000000000000000000..c7b859c78b526273d3ba51202dc6226ae758ca08 --- /dev/null +++ b/solverforge.app.toml @@ -0,0 +1,100 @@ +[app] +name = "solverforge-fsr" +starter = "neutral-shell" +cli_version = "2.0.4" + +[runtime] +target = "solverforge 0.13.1" +runtime_source = "crates.io: solverforge 0.13.1" +ui_source = "crates.io: solverforge-ui 0.6.5" +maps_source = "crates.io: solverforge-maps 2.1.4" + +[demo] +default_size = "standard" +available_sizes = [ + "standard", +] + +[solution] +name = "FieldServicePlan" +score = "HardSoftScore" + +[[facts]] +name = "location" +plural = "locations" +kind = "problem_fact" + +[[facts]] +name = "service_visit" +plural = "service_visits" +kind = "problem_fact" + +[[facts]] +name = "travel_leg" +plural = "travel_legs" +kind = "problem_fact" + +[[entities]] +name = "technician_route" +plural = "technician_routes" +kind = "planning_entity" + +[[variables]] +entity = "technician_route" +entity_plural = "technician_routes" +field = "visits" +kind = "list" +range = "" +elements = "service_visits" +allows_unassigned = false +enabled = true + +[[constraints]] +name = "assigned_visits" +module = "assigned_visits" +enabled = true + +[[constraints]] +name = "balance_workload" +module = "balance_workload" +enabled = true + +[[constraints]] +name = "minimize_travel" +module = "minimize_travel" +enabled = true + +[[constraints]] +name = "priority_slack" +module = "priority_slack" +enabled = true + +[[constraints]] +name = "reachable_legs" +module = "reachable_legs" +enabled = true + +[[constraints]] +name = "required_parts" +module = "required_parts" +enabled = true + +[[constraints]] +name = "required_skills" +module = "required_skills" +enabled = true + +[[constraints]] +name = "shift_capacity" +module = "shift_capacity" +enabled = true + +[[constraints]] +name = "territory_affinity" +module = "territory_affinity" +enabled = true + +[[constraints]] +name = "time_windows" +module = "time_windows" +enabled = true diff --git a/src/api/dto.rs b/src/api/dto.rs new file mode 100644 index 0000000000000000000000000000000000000000..9fb6ae9bf581b8f4f3596d7420cc0b1dfe1b71ce --- /dev/null +++ b/src/api/dto.rs @@ -0,0 +1,255 @@ +//! Browser-facing JSON types for FSR retained jobs. +//! +//! The domain model is optimized for SolverForge joins and score calculation. +//! DTOs keep the HTTP contract stable and browser-friendly, including string +//! score labels and camelCase field names. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use solverforge::{ + HardSoftScore, SolverLifecycleState, SolverSnapshot, SolverSnapshotAnalysis, SolverStatus, + SolverTelemetry, SolverTerminalReason, +}; +use std::time::Duration; + +use crate::domain::FieldServicePlan; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanDto { + /// Flattened domain fields let the stock UI metadata describe facts and + /// entities without a hand-written transport struct for every collection. + #[serde(flatten)] + pub fields: Map, + #[serde(default)] + pub score: Option, +} + +/// Constraint analysis result. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConstraintAnalysisDto { + pub name: String, + pub weight: String, + pub score: String, + pub match_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalyzeResponse { + pub score: String, + pub constraints: Vec, +} + +#[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, +} + +#[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, +} + +#[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, +} + +#[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 { + pub fn from_plan(plan: &FieldServicePlan) -> Self { + let mut fields = match serde_json::to_value(plan).expect("failed to serialize plan") { + Value::Object(map) => map, + _ => Map::new(), + }; + let score = fields.remove("score").and_then(|value| { + if value.is_null() { + None + } else if let Some(score) = value.as_str() { + Some(score.to_string()) + } else { + Some(value.to_string()) + } + }); + + Self { fields, score } + } + + pub fn to_domain(&self) -> Result { + let mut fields = self.fields.clone(); + let _ = &self.score; + fields.insert("score".to_string(), Value::Null); + serde_json::from_value(Value::Object(fields)) + } +} + +impl TelemetryDto { + pub fn from_runtime(telemetry: &SolverTelemetry) -> Self { + Self { + 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, + ), + } + } +} + +impl JobSummaryDto { + 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 { + 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 { + 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, + } + } +} + +pub fn analysis_response(analysis: &solverforge::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(), + } +} + +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", + } +} + +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", + } +} + +fn duration_to_millis(duration: Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + +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 + } +} + +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 + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..a64f1996411554458148c6d9a3c8864117447645 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,13 @@ +//! HTTP transport surface for the field-service routing app. +//! +//! Routes decode browser requests, DTOs define the JSON contract, route +//! geometry adapts road-network output, and `SolverService` owns retained jobs. + +mod dto; +mod route_dto; +mod route_geometry; +mod routes; +mod sse; + +pub use dto::PlanDto; +pub use routes::{router, AppState}; diff --git a/src/api/route_dto.rs b/src/api/route_dto.rs new file mode 100644 index 0000000000000000000000000000000000000000..a620d935c33243da66f14bd693fcb53e87975be8 --- /dev/null +++ b/src/api/route_dto.rs @@ -0,0 +1,57 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobRoutesDto { + pub id: String, + pub job_id: String, + pub snapshot_revision: u64, + pub routes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TechnicianRouteGeometryDto { + pub route_id: String, + pub technician_id: String, + pub technician_name: String, + pub color: String, + pub segments: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RouteGeometryStatus { + Routed, + UnreachableLeg, + SnapFailed, + NoPath, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteSegmentDto { + pub route_id: String, + pub from_location_idx: usize, + pub to_location_idx: usize, + pub duration_seconds: i64, + pub distance_meters: i64, + pub reachable: bool, + pub geometry_status: RouteGeometryStatus, + pub encoded_polyline: String, +} + +impl JobRoutesDto { + pub fn new( + job_id: usize, + snapshot_revision: u64, + routes: Vec, + ) -> Self { + Self { + id: job_id.to_string(), + job_id: job_id.to_string(), + snapshot_revision, + routes, + } + } +} diff --git a/src/api/route_geometry.rs b/src/api/route_geometry.rs new file mode 100644 index 0000000000000000000000000000000000000000..909b006d9cfdc56622db0e015ab7c841d04a2014 --- /dev/null +++ b/src/api/route_geometry.rs @@ -0,0 +1,286 @@ +//! Browser route-geometry builder for FSR snapshots. +//! +//! Scoring uses cached `TravelLeg` facts so local search remains cheap. The +//! browser asks for drawable road geometry only for retained snapshots, and this +//! module converts those route legs into per-segment DTOs. + +use axum::http::StatusCode; + +use super::route_dto::{RouteGeometryStatus, RouteSegmentDto, TechnicianRouteGeometryDto}; +use crate::data::{load_network, DemoDataError}; +use crate::domain::{FieldServicePlan, TravelLeg}; + +pub(super) fn status_from_routing_error(error: solverforge_maps::RoutingError) -> StatusCode { + eprintln!("Bergamo route geometry failed: {error}"); + match error { + solverforge_maps::RoutingError::InvalidCoordinate { .. } => StatusCode::BAD_REQUEST, + solverforge_maps::RoutingError::Cancelled => StatusCode::REQUEST_TIMEOUT, + solverforge_maps::RoutingError::Network(_) + | solverforge_maps::RoutingError::Parse(_) + | solverforge_maps::RoutingError::Io(_) + | solverforge_maps::RoutingError::SnapFailed { .. } + | solverforge_maps::RoutingError::NoPath { .. } => StatusCode::BAD_GATEWAY, + } +} + +pub(super) async fn build_route_geometry( + plan: &FieldServicePlan, +) -> Result, solverforge_maps::RoutingError> { + // Geometry is built on demand for retained snapshots. It is separate from + // scoring so local search can use cached matrix facts without asking the map + // service to draw every candidate move. + let network = load_network().await.map_err(|error| match error { + DemoDataError::Routing(error) => error, + })?; + let mut routes = Vec::with_capacity(plan.technician_routes.len()); + + for route in &plan.technician_routes { + let mut segments = Vec::new(); + let mut previous_location_idx = route.start_location_idx; + for &visit_idx in &route.visits { + let Some(visit) = plan.service_visits.get(visit_idx) else { + continue; + }; + segments.push(build_route_segment( + plan, + &network, + &route.id, + previous_location_idx, + visit.location_idx, + )?); + previous_location_idx = visit.location_idx; + } + if !route.visits.is_empty() { + segments.push(build_route_segment( + plan, + &network, + &route.id, + previous_location_idx, + route.end_location_idx, + )?); + } + + routes.push(TechnicianRouteGeometryDto { + route_id: route.id.clone(), + technician_id: route.technician_id.clone(), + technician_name: route.technician_name.clone(), + color: route.color.clone(), + segments, + }); + } + + Ok(routes) +} + +fn build_route_segment( + plan: &FieldServicePlan, + network: &solverforge_maps::RoadNetwork, + route_id: &str, + from_location_idx: usize, + to_location_idx: usize, +) -> Result { + let travel_leg = find_travel_leg(plan, from_location_idx, to_location_idx); + if !travel_leg.is_some_and(|leg| leg.reachable) { + // Preserve the segment in the response even when it cannot be drawn. + // The UI can then show a partial route instead of hiding useful legs. + return Ok(non_routed_segment( + route_id, + from_location_idx, + to_location_idx, + travel_leg, + RouteGeometryStatus::UnreachableLeg, + )); + } + + let from = plan.locations.get(from_location_idx).ok_or_else(|| { + solverforge_maps::RoutingError::Network("route source location missing".into()) + })?; + let to = plan.locations.get(to_location_idx).ok_or_else(|| { + solverforge_maps::RoutingError::Network("route target location missing".into()) + })?; + + let route_result = network.route( + solverforge_maps::Coord::new(from.lat(), from.lng()), + solverforge_maps::Coord::new(to.lat(), to.lng()), + ); + let route = match route_result { + Ok(route) => route.simplify(12.0), + Err(error) => { + // Snap and no-path failures are segment-level map problems. Treat + // them as display status rather than failing the whole snapshot. + if let Some(status) = recoverable_geometry_status(&error) { + return Ok(non_routed_segment( + route_id, + from_location_idx, + to_location_idx, + travel_leg, + status, + )); + } + return Err(error); + } + }; + + Ok(RouteSegmentDto { + route_id: route_id.to_string(), + from_location_idx, + to_location_idx, + duration_seconds: route.duration_seconds, + distance_meters: route.distance_meters.round() as i64, + reachable: true, + geometry_status: RouteGeometryStatus::Routed, + encoded_polyline: solverforge_maps::encode_polyline(&route.geometry), + }) +} + +fn find_travel_leg( + plan: &FieldServicePlan, + from_location_idx: usize, + to_location_idx: usize, +) -> Option<&TravelLeg> { + let width = plan.locations.len(); + plan.travel_legs + .get(from_location_idx.checked_mul(width)? + to_location_idx) + .filter(|leg| { + leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx + }) + .or_else(|| { + plan.travel_legs.iter().find(|leg| { + leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx + }) + }) +} + +fn non_routed_segment( + route_id: &str, + from_location_idx: usize, + to_location_idx: usize, + travel_leg: Option<&TravelLeg>, + geometry_status: RouteGeometryStatus, +) -> RouteSegmentDto { + RouteSegmentDto { + route_id: route_id.to_string(), + from_location_idx, + to_location_idx, + duration_seconds: travel_leg.map_or(0, |leg| leg.duration_seconds), + distance_meters: travel_leg.map_or(0, |leg| leg.distance_meters), + reachable: false, + geometry_status, + encoded_polyline: String::new(), + } +} + +fn recoverable_geometry_status( + error: &solverforge_maps::RoutingError, +) -> Option { + match error { + solverforge_maps::RoutingError::SnapFailed { .. } => Some(RouteGeometryStatus::SnapFailed), + solverforge_maps::RoutingError::NoPath { .. } => Some(RouteGeometryStatus::NoPath), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{FieldServicePlan, Location, TravelLegInit}; + + #[test] + fn finds_dense_or_sparse_travel_leg() { + let plan = test_plan(vec![TravelLeg::new(TravelLegInit { + id: "leg-01-02".to_string(), + name: "leg-01-02".to_string(), + from_location_idx: 1, + to_location_idx: 2, + duration_seconds: 42, + distance_meters: 1000, + reachable: true, + })]); + + let leg = find_travel_leg(&plan, 1, 2).expect("travel leg"); + + assert_eq!(leg.duration_seconds, 42); + } + + #[test] + fn non_routed_segment_preserves_known_metrics() { + let plan = test_plan(vec![TravelLeg::new(TravelLegInit { + id: "leg-00-01".to_string(), + name: "leg-00-01".to_string(), + from_location_idx: 0, + to_location_idx: 1, + duration_seconds: 90, + distance_meters: 1200, + reachable: false, + })]); + + let segment = non_routed_segment( + "route-00", + 0, + 1, + find_travel_leg(&plan, 0, 1), + RouteGeometryStatus::UnreachableLeg, + ); + + assert!(!segment.reachable); + assert_eq!(segment.geometry_status, RouteGeometryStatus::UnreachableLeg); + assert_eq!(segment.duration_seconds, 90); + assert!(segment.encoded_polyline.is_empty()); + } + + #[test] + fn only_snap_and_no_path_are_recoverable_segment_failures() { + let from = solverforge_maps::Coord::new(45.0, 9.0); + let to = solverforge_maps::Coord::new(46.0, 10.0); + + assert_eq!( + recoverable_geometry_status(&solverforge_maps::RoutingError::NoPath { from, to }), + Some(RouteGeometryStatus::NoPath) + ); + assert_eq!( + recoverable_geometry_status(&solverforge_maps::RoutingError::SnapFailed { + coord: from, + nearest_distance_m: None, + }), + Some(RouteGeometryStatus::SnapFailed) + ); + assert_eq!( + recoverable_geometry_status(&solverforge_maps::RoutingError::Network("down".into())), + None + ); + } + + fn test_plan(travel_legs: Vec) -> FieldServicePlan { + FieldServicePlan::new( + vec![ + Location::new( + "loc-0", + "loc-0", + "A".into(), + 45_000_000, + 9_000_000, + "x".into(), + ), + Location::new( + "loc-1", + "loc-1", + "B".into(), + 45_001_000, + 9_001_000, + "x".into(), + ), + Location::new( + "loc-2", + "loc-2", + "C".into(), + 45_002_000, + 9_002_000, + "x".into(), + ), + ], + Vec::new(), + travel_legs, + Vec::new(), + ) + } +} diff --git a/src/api/routes.rs b/src/api/routes.rs new file mode 100644 index 0000000000000000000000000000000000000000..8d1fb69dd6ba317fc0091c1b88ba4adb8b75f789 --- /dev/null +++ b/src/api/routes.rs @@ -0,0 +1,251 @@ +//! HTTP routes for the field-service routing app. +//! +//! Handlers intentionally stay narrow: parse the route/query, ask the data or +//! retained solver service for the domain value, then return a DTO. + +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::route_dto::JobRoutesDto; +use super::route_geometry::{build_route_geometry, status_from_routing_error}; +use super::sse; +use crate::data::{generate, prepare_routing, DemoData, DemoDataError}; +use crate::solver::SolverService; + +/// Shared application state. +pub struct AppState { + pub solver: SolverService, +} + +impl AppState { + pub fn new() -> Self { + Self { + solver: SolverService::new(), + } + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +/// Creates the API router. +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}/routes", get(get_routes)) + .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, +} + +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, +} + +async fn info() -> Json { + Json(InfoResponse { + name: env!("CARGO_PKG_NAME"), + version: env!("CARGO_PKG_VERSION"), + solver_engine: "SolverForge", + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DemoDataCatalogResponse { + default_id: &'static str, + available_ids: Vec<&'static str>, +} + +async fn list_demo_data() -> Json { + Json(DemoDataCatalogResponse { + default_id: DemoData::default_demo_data().id(), + available_ids: DemoData::available_demo_data() + .iter() + .map(|demo| demo.id()) + .collect(), + }) +} + +async fn get_demo_data(Path(id): Path) -> Result, StatusCode> { + let demo = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + let plan = generate(demo).await.map_err(status_from_demo_data_error)?; + Ok(Json(PlanDto::from_plan(&plan))) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateJobResponse { + id: String, +} + +async fn create_job( + State(state): State>, + Json(dto): Json, +) -> Result, StatusCode> { + let mut plan = dto.to_domain().map_err(|_| StatusCode::BAD_REQUEST)?; + prepare_routing(&mut plan) + .await + .map_err(status_from_demo_data_error)?; + let id = state + .solver + .start_job(plan) + .map_err(status_from_solver_error)?; + Ok(Json(CreateJobResponse { id })) +} + +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))) +} + +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, +} + +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))) +} + +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, + ))) +} + +async fn get_routes( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let job_id = parse_job_id(&id)?; + let snapshot = state + .solver + .get_snapshot(&id, query.snapshot_revision) + .map_err(status_from_solver_error)?; + let routes = build_route_geometry(&snapshot.solution) + .await + .map_err(status_from_routing_error)?; + Ok(Json(JobRoutesDto::new( + job_id, + snapshot.snapshot_revision, + routes, + ))) +} + +async fn pause_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.pause(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +async fn resume_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.resume(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +async fn cancel_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.cancel(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +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) +} + +fn parse_job_id(id: &str) -> Result { + id.parse::().map_err(|_| StatusCode::NOT_FOUND) +} + +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, + } +} + +fn status_from_demo_data_error(error: DemoDataError) -> StatusCode { + eprintln!("{error}"); + StatusCode::SERVICE_UNAVAILABLE +} diff --git a/src/api/sse.rs b/src/api/sse.rs new file mode 100644 index 0000000000000000000000000000000000000000..dec904de251349e6945c855dca0ea528d820c1fc --- /dev/null +++ b/src/api/sse.rs @@ -0,0 +1,71 @@ +//! Server-sent events for retained FSR solve jobs. +//! +//! The first frame is a bootstrap status or snapshot so late subscribers can +//! render immediately. Later frames come from the job's broadcast channel. + +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; + +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); + let bootstrap = tokio_stream::iter(std::iter::once(Ok::<_, std::convert::Infallible>( + format!("data: {}\n\n", bootstrap_json).into_bytes(), + ))); + + 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, // Lagged - skip missed messages + }); + + 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()) +} + +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) + }) +} + +/// Returns true when a live event is already covered by the bootstrap frame. +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_visits.rs b/src/constraints/assigned_visits.rs new file mode 100644 index 0000000000000000000000000000000000000000..a8f29b22fd9f77ac89df9d19414c4fe618f0b864 --- /dev/null +++ b/src/constraints/assigned_visits.rs @@ -0,0 +1,191 @@ +//! Assignment-coverage rule for field-service visits. +//! +//! List variables should contain each service visit exactly once. This rule +//! catches three beginner-relevant failures: a missing visit, a duplicated visit, +//! and a route list entry that points outside the visit collection. + +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; +use solverforge_core::ConstraintRef; + +/// HARD: every service visit must appear exactly once in a technician route. +pub fn constraint() -> impl IncrementalConstraint { + AssignedVisitsConstraint::new() +} + +struct AssignedVisitsConstraint { + constraint_ref: ConstraintRef, +} + +impl AssignedVisitsConstraint { + fn new() -> Self { + Self { + constraint_ref: ConstraintRef::new("field_service_routing", "Assigned Visits"), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct AssignmentIssues { + unassigned: i64, + duplicate_assignments: i64, + invalid_assignments: i64, +} + +impl AssignmentIssues { + fn total(self) -> i64 { + self.unassigned + self.duplicate_assignments + self.invalid_assignments + } +} + +impl IncrementalConstraint for AssignedVisitsConstraint { + fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore { + HardSoftScore::of(-assignment_issues(solution).total(), 0) + } + + fn match_count(&self, solution: &FieldServicePlan) -> usize { + assignment_issues(solution).total() as usize + } + + fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore { + self.evaluate(solution) + } + + fn on_insert( + &mut self, + solution: &FieldServicePlan, + _entity_index: usize, + _descriptor_index: usize, + ) -> HardSoftScore { + self.evaluate(solution) + } + + fn on_retract( + &mut self, + solution: &FieldServicePlan, + _entity_index: usize, + _descriptor_index: usize, + ) -> HardSoftScore { + -self.evaluate(solution) + } + + fn reset(&mut self) {} + + fn name(&self) -> &str { + &self.constraint_ref.name + } + + fn is_hard(&self) -> bool { + true + } + + fn weight(&self) -> HardSoftScore { + HardSoftScore::of(1, 0) + } + + fn constraint_ref(&self) -> &ConstraintRef { + &self.constraint_ref + } +} + +fn assignment_issues(plan: &FieldServicePlan) -> AssignmentIssues { + // `counts[i]` records how often service visit `i` appears across every + // technician route. A valid list-variable solution leaves every count at 1. + let mut counts = vec![0usize; plan.service_visits.len()]; + let mut issues = AssignmentIssues::default(); + + for route in &plan.technician_routes { + for &visit_idx in &route.visits { + if let Some(count) = counts.get_mut(visit_idx) { + *count += 1; + } else { + issues.invalid_assignments += 1; + } + } + } + + for count in counts { + match count { + 0 => issues.unassigned += 1, + 1 => {} + extra => issues.duplicate_assignments += (extra - 1) as i64, + } + } + + issues +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{ + FieldServicePlan, ServiceVisit, ServiceVisitInit, TechnicianRoute, TechnicianRouteInit, + }; + use solverforge::IncrementalConstraint; + + #[test] + fn empty_routes_are_penalized_for_unassigned_visits() { + let score = constraint().evaluate(&sample_plan(vec![vec![]])); + + assert_eq!(score, HardSoftScore::of(-2, 0)); + } + + #[test] + fn every_visit_once_is_feasible() { + let score = constraint().evaluate(&sample_plan(vec![vec![0, 1]])); + + assert_eq!(score, HardSoftScore::ZERO); + } + + #[test] + fn duplicate_or_invalid_visit_indexes_are_hard_issues() { + let score = constraint().evaluate(&sample_plan(vec![vec![0, 0, 99]])); + + assert_eq!(score, HardSoftScore::of(-3, 0)); + } + + fn sample_plan(route_visits: Vec>) -> FieldServicePlan { + let service_visits = (0..2) + .map(|idx| { + ServiceVisit::new(ServiceVisitInit { + id: format!("visit-{idx}"), + name: format!("Visit {idx}"), + customer: format!("Customer {idx}"), + location_idx: idx, + duration_minutes: 30, + earliest_minute: 480, + latest_minute: 1020, + required_skill_mask: 0, + required_parts_mask: 0, + priority: 1, + territory: "center".to_string(), + }) + }) + .collect(); + let technician_routes = route_visits + .into_iter() + .enumerate() + .map(|(idx, visits)| { + let mut route = TechnicianRoute::new(TechnicianRouteInit { + id: format!("route-{idx}"), + technician_id: format!("tech-{idx}"), + technician_name: format!("Tech {idx}"), + color: "#2563eb".to_string(), + start_location_idx: 0, + end_location_idx: 0, + shift_start_minute: 480, + shift_end_minute: 1020, + max_route_minutes: 480, + skill_mask: 0, + inventory_mask: 0, + territory: "center".to_string(), + }); + route.visits = visits; + route + }) + .collect(); + + FieldServicePlan::new(Vec::new(), service_visits, Vec::new(), technician_routes) + } +} diff --git a/src/constraints/balance_workload.rs b/src/constraints/balance_workload.rs new file mode 100644 index 0000000000000000000000000000000000000000..094c0bf3e7ef73e4e71dc6b33d02a0502887aeba --- /dev/null +++ b/src/constraints/balance_workload.rs @@ -0,0 +1,17 @@ +use crate::constraints::route_metrics::{ + balance_workload_match_count, balance_workload_score, RouteConstraint, +}; +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// SOFT: discourage concentrating all service and travel minutes on one route. +pub fn constraint() -> impl IncrementalConstraint { + RouteConstraint::new( + "Balance Workload", + false, + HardSoftScore::of(0, 1), + balance_workload_score, + balance_workload_match_count, + ) +} diff --git a/src/constraints/minimize_travel.rs b/src/constraints/minimize_travel.rs new file mode 100644 index 0000000000000000000000000000000000000000..d866b6595a165f819946bc9960e97fb6ea0d6cf3 --- /dev/null +++ b/src/constraints/minimize_travel.rs @@ -0,0 +1,17 @@ +use crate::constraints::route_metrics::{ + minimize_travel_match_count, minimize_travel_score, RouteConstraint, +}; +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// SOFT: minimize road travel time and distance across technician routes. +pub fn constraint() -> impl IncrementalConstraint { + RouteConstraint::new( + "Minimize Travel", + false, + HardSoftScore::of(0, 1), + minimize_travel_score, + minimize_travel_match_count, + ) +} diff --git a/src/constraints/mod.rs b/src/constraints/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..b8ec690f06aa88a557da4eb87c01b7ac83906778 --- /dev/null +++ b/src/constraints/mod.rs @@ -0,0 +1,50 @@ +//! Constraint assembly for field-service routing. +//! +//! Each child module owns one business rule. The small wrappers delegate shared +//! route walking to `route_metrics`, so beginner readers can learn the rule +//! names here before opening the scoring math. + +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; + +pub use self::assemble::create_constraints; + +mod route_constraint; +pub mod route_metrics; +#[cfg(test)] +mod route_metrics_tests; + +// @solverforge:begin constraint-modules +mod assigned_visits; +mod balance_workload; +mod minimize_travel; +mod priority_slack; +mod reachable_legs; +mod required_parts; +mod required_skills; +mod shift_capacity; +mod territory_affinity; +mod time_windows; +// @solverforge:end constraint-modules + +mod assemble { + use super::*; + + /// Collects the full scoring model used by `FieldServicePlan`. + pub fn create_constraints() -> impl ConstraintSet { + // @solverforge:begin constraint-calls + ( + assigned_visits::constraint(), + balance_workload::constraint(), + minimize_travel::constraint(), + priority_slack::constraint(), + reachable_legs::constraint(), + required_parts::constraint(), + required_skills::constraint(), + shift_capacity::constraint(), + territory_affinity::constraint(), + time_windows::constraint(), + ) + // @solverforge:end constraint-calls + } +} diff --git a/src/constraints/priority_slack.rs b/src/constraints/priority_slack.rs new file mode 100644 index 0000000000000000000000000000000000000000..28af8a42f3d1ae3f54f50f19aa2b2e8e41028ac6 --- /dev/null +++ b/src/constraints/priority_slack.rs @@ -0,0 +1,17 @@ +use crate::constraints::route_metrics::{ + priority_slack_match_count, priority_slack_score, RouteConstraint, +}; +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// SOFT: reward serving high-priority visits with slack before their deadline. +pub fn constraint() -> impl IncrementalConstraint { + RouteConstraint::new( + "Priority Slack", + false, + HardSoftScore::of(0, 1), + priority_slack_score, + priority_slack_match_count, + ) +} diff --git a/src/constraints/reachable_legs.rs b/src/constraints/reachable_legs.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d343b554d2cad41ce51a91e8989d66c38a75103 --- /dev/null +++ b/src/constraints/reachable_legs.rs @@ -0,0 +1,15 @@ +use crate::constraints::route_metrics::{reachable_match_count, reachable_score, RouteConstraint}; +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: every depot-to-visit, visit-to-visit, and visit-to-depot leg must be routable. +pub fn constraint() -> impl IncrementalConstraint { + RouteConstraint::new( + "Reachable Legs", + true, + HardSoftScore::of(1, 0), + reachable_score, + reachable_match_count, + ) +} diff --git a/src/constraints/required_parts.rs b/src/constraints/required_parts.rs new file mode 100644 index 0000000000000000000000000000000000000000..d38d85903e524eb5d156934b126d82386eac1159 --- /dev/null +++ b/src/constraints/required_parts.rs @@ -0,0 +1,17 @@ +use crate::constraints::route_metrics::{ + required_parts_match_count, required_parts_score, RouteConstraint, +}; +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: route inventory must cover every assigned visit's required parts. +pub fn constraint() -> impl IncrementalConstraint { + RouteConstraint::new( + "Required Parts", + true, + HardSoftScore::of(1, 0), + required_parts_score, + required_parts_match_count, + ) +} diff --git a/src/constraints/required_skills.rs b/src/constraints/required_skills.rs new file mode 100644 index 0000000000000000000000000000000000000000..246850e4a5ccbb60fb4ca7ad5906c3b3610609b6 --- /dev/null +++ b/src/constraints/required_skills.rs @@ -0,0 +1,17 @@ +use crate::constraints::route_metrics::{ + required_skills_match_count, required_skills_score, RouteConstraint, +}; +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: a technician route may only contain visits whose skill mask is covered. +pub fn constraint() -> impl IncrementalConstraint { + RouteConstraint::new( + "Required Skills", + true, + HardSoftScore::of(1, 0), + required_skills_score, + required_skills_match_count, + ) +} diff --git a/src/constraints/route_constraint.rs b/src/constraints/route_constraint.rs new file mode 100644 index 0000000000000000000000000000000000000000..e8ce725f7cdde5c6e911c227e8b1eb20970fd424 --- /dev/null +++ b/src/constraints/route_constraint.rs @@ -0,0 +1,104 @@ +//! Small reusable incremental constraint wrapper for route-level rules. +//! +//! Most FSR rules score one technician route at a time. This adapter keeps that +//! pattern explicit: a rule provides a route scorer and match counter, while +//! SolverForge calls `on_insert` and `on_retract` when a route entity changes. + +use crate::domain::{FieldServicePlan, TechnicianRoute}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; +use solverforge_core::ConstraintRef; + +pub struct RouteConstraint { + constraint_ref: ConstraintRef, + hard: bool, + weight: HardSoftScore, + scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore, + match_counter: fn(&FieldServicePlan, &TechnicianRoute) -> usize, +} + +impl RouteConstraint { + /// Creates a named route-level scoring rule. + pub fn new( + name: &'static str, + hard: bool, + weight: HardSoftScore, + scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore, + match_counter: fn(&FieldServicePlan, &TechnicianRoute) -> usize, + ) -> Self { + Self { + constraint_ref: ConstraintRef::new("field_service_routing", name), + hard, + weight, + scorer, + match_counter, + } + } + + /// Computes only the changed route's score for incremental callbacks. + fn route_score(&self, solution: &FieldServicePlan, entity_index: usize) -> HardSoftScore { + solution + .technician_routes + .get(entity_index) + .map(|route| (self.scorer)(solution, route)) + .unwrap_or(HardSoftScore::ZERO) + } +} + +impl IncrementalConstraint for RouteConstraint { + fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore { + solution + .technician_routes + .iter() + .map(|route| (self.scorer)(solution, route)) + .fold(HardSoftScore::ZERO, |total, score| total + score) + } + + fn match_count(&self, solution: &FieldServicePlan) -> usize { + solution + .technician_routes + .iter() + .map(|route| (self.match_counter)(solution, route)) + .sum() + } + + fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore { + self.evaluate(solution) + } + + fn on_insert( + &mut self, + solution: &FieldServicePlan, + entity_index: usize, + _descriptor_index: usize, + ) -> HardSoftScore { + self.route_score(solution, entity_index) + } + + fn on_retract( + &mut self, + solution: &FieldServicePlan, + entity_index: usize, + _descriptor_index: usize, + ) -> HardSoftScore { + -self.route_score(solution, entity_index) + } + + fn reset(&mut self) {} + + fn name(&self) -> &str { + &self.constraint_ref.name + } + + fn is_hard(&self) -> bool { + self.hard + } + + fn weight(&self) -> HardSoftScore { + self.weight + } + + fn constraint_ref(&self) -> &ConstraintRef { + &self.constraint_ref + } +} diff --git a/src/constraints/route_metrics.rs b/src/constraints/route_metrics.rs new file mode 100644 index 0000000000000000000000000000000000000000..9a8b64209c74aa2c3d6c4fba1d4be9d23705b3b2 --- /dev/null +++ b/src/constraints/route_metrics.rs @@ -0,0 +1,284 @@ +//! Shared route measurements used by field-service constraints. +//! +//! SolverForge calls each constraint separately, but the business concepts +//! overlap: travel, time windows, skills, parts, overtime, and priority slack +//! all require walking the same ordered visit list. This module centralizes that +//! walk so the individual constraint files stay easy to read. + +use crate::domain::{FieldServicePlan, ServiceVisit, TechnicianRoute, TravelLeg}; +use solverforge::prelude::*; + +pub use super::route_constraint::RouteConstraint; + +/// Aggregated measurements for one technician route. +/// +/// Individual constraints reuse this struct so each business rule can stay +/// small. For example, the time-window constraint reads `late_minutes`, while +/// the travel minimization rule reads `travel_seconds` and `distance_meters`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RouteStats { + pub invalid_visits: i64, + pub valid_visits: i64, + pub scored_travel_legs: i64, + pub unreachable_legs: i64, + pub missing_skill_visits: i64, + pub missing_part_visits: i64, + pub late_visits: i64, + pub late_minutes: i64, + pub overtime_minutes: i64, + pub travel_seconds: i64, + pub distance_meters: i64, + pub service_minutes: i64, + pub waiting_minutes: i64, + pub route_minutes: i64, + pub finish_minute: i32, + pub territory_matches: i64, + pub priority_slack: i64, +} + +impl RouteStats { + pub fn travel_minutes(&self) -> i64 { + div_ceil(self.travel_seconds, 60) + } +} + +#[derive(Debug, Clone, Copy)] +struct VisitTiming { + visit_idx: usize, + service_start: i32, +} + +pub fn route_stats(plan: &FieldServicePlan, route: &TechnicianRoute) -> RouteStats { + let mut stats = RouteStats { + finish_minute: route.shift_start_minute, + ..RouteStats::default() + }; + let mut clock = route.shift_start_minute; + let mut previous_location = route.start_location_idx; + let mut timings = Vec::with_capacity(route.visits.len()); + + // Walk the route in visit order. This mirrors how a technician would drive: + // depot to first visit, visit to visit, then back to the end depot. + for &visit_idx in &route.visits { + let Some(visit) = plan.service_visits.get(visit_idx) else { + stats.invalid_visits += 1; + continue; + }; + stats.valid_visits += 1; + + apply_leg( + plan, + previous_location, + visit.location_idx, + &mut clock, + &mut stats, + ); + + // Waiting is allowed and soft-neutral; lateness is a hard feasibility + // problem scored by the time-window constraint. + if clock < visit.earliest_minute { + stats.waiting_minutes += i64::from(visit.earliest_minute - clock); + clock = visit.earliest_minute; + } + if clock > visit.latest_minute { + stats.late_visits += 1; + stats.late_minutes += i64::from(clock - visit.latest_minute); + } + + if !mask_contains(route.skill_mask, visit.required_skill_mask) { + stats.missing_skill_visits += 1; + } + if !mask_contains(route.inventory_mask, visit.required_parts_mask) { + stats.missing_part_visits += 1; + } + if route.territory == visit.territory { + stats.territory_matches += 1; + } + + timings.push(VisitTiming { + visit_idx, + service_start: clock, + }); + + let service_minutes = visit.duration_minutes.max(0); + stats.service_minutes += i64::from(service_minutes); + clock = clock.saturating_add(service_minutes); + previous_location = visit.location_idx; + } + + apply_leg( + plan, + previous_location, + route.end_location_idx, + &mut clock, + &mut stats, + ); + + stats.finish_minute = clock; + stats.route_minutes = i64::from(clock.saturating_sub(route.shift_start_minute)); + stats.overtime_minutes = i64::from((clock - route.shift_end_minute).max(0)) + + (stats.route_minutes - i64::from(route.max_route_minutes)).max(0); + stats.priority_slack = priority_slack(plan, &timings); + stats +} + +pub fn reachable_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore { + let stats = route_stats(plan, route); + HardSoftScore::of(-(stats.invalid_visits + stats.unreachable_legs), 0) +} + +pub fn reachable_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize { + let stats = route_stats(plan, route); + count_matches(stats.invalid_visits + stats.unreachable_legs) +} + +pub fn required_skills_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore { + HardSoftScore::of(-route_stats(plan, route).missing_skill_visits, 0) +} + +pub fn required_skills_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize { + count_matches(route_stats(plan, route).missing_skill_visits) +} + +pub fn required_parts_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore { + HardSoftScore::of(-route_stats(plan, route).missing_part_visits, 0) +} + +pub fn required_parts_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize { + count_matches(route_stats(plan, route).missing_part_visits) +} + +pub fn time_windows_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore { + HardSoftScore::of(-route_stats(plan, route).late_minutes, 0) +} + +pub fn time_windows_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize { + count_matches(route_stats(plan, route).late_visits) +} + +pub fn shift_capacity_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore { + HardSoftScore::of(-route_stats(plan, route).overtime_minutes, 0) +} + +pub fn shift_capacity_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize { + usize::from(route_stats(plan, route).overtime_minutes > 0) +} + +pub fn minimize_travel_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore { + let stats = route_stats(plan, route); + let travel_minutes = stats.travel_minutes(); + let travel_km = div_ceil(stats.distance_meters, 1_000); + HardSoftScore::of(0, -(travel_minutes + travel_km)) +} + +pub fn minimize_travel_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize { + count_matches(route_stats(plan, route).scored_travel_legs) +} + +pub fn balance_workload_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore { + let normalized = (route_stats(plan, route).route_minutes / 15).max(0); + HardSoftScore::of(0, -(normalized * normalized)) +} + +pub fn balance_workload_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize { + usize::from(route_stats(plan, route).route_minutes > 0) +} + +pub fn territory_affinity_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore { + HardSoftScore::of(0, route_stats(plan, route).territory_matches * 25) +} + +pub fn territory_affinity_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize { + count_matches(route_stats(plan, route).territory_matches) +} + +pub fn priority_slack_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore { + HardSoftScore::of(0, route_stats(plan, route).priority_slack) +} + +pub fn priority_slack_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize { + count_matches(route_stats(plan, route).valid_visits) +} + +pub fn leg_for( + plan: &FieldServicePlan, + from_location_idx: usize, + to_location_idx: usize, +) -> Option<&TravelLeg> { + let width = plan.locations.len(); + // Travel legs are normally stored as a dense row-major matrix. The secondary + // scan keeps tests and sparse diagnostics readable without changing the + // public fact shape. + let direct_idx = from_location_idx + .checked_mul(width)? + .checked_add(to_location_idx)?; + + if let Some(leg) = plan.travel_legs.get(direct_idx) { + if leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx { + return Some(leg); + } + } + + plan.travel_legs.iter().find(|leg| { + leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx + }) +} + +fn apply_leg( + plan: &FieldServicePlan, + from_location_idx: usize, + to_location_idx: usize, + clock: &mut i32, + stats: &mut RouteStats, +) { + let Some(leg) = leg_for(plan, from_location_idx, to_location_idx) else { + stats.unreachable_legs += 1; + return; + }; + + if !leg.reachable { + stats.unreachable_legs += 1; + return; + } + + // Scoring uses seconds for precision but the route clock advances in whole + // minutes because visits and shifts are modeled on a minute calendar. + stats.travel_seconds += leg.duration_seconds.max(0); + stats.distance_meters += leg.distance_meters.max(0); + if leg.duration_seconds > 0 || leg.distance_meters > 0 { + stats.scored_travel_legs += 1; + } + *clock = clock.saturating_add(div_ceil(leg.duration_seconds.max(0), 60) as i32); +} + +fn priority_slack(plan: &FieldServicePlan, timings: &[VisitTiming]) -> i64 { + timings + .iter() + .filter_map(|timing| { + plan.service_visits + .get(timing.visit_idx) + .map(|visit| visit_priority_slack(visit, timing.service_start)) + }) + .sum() +} + +fn visit_priority_slack(visit: &ServiceVisit, service_start: i32) -> i64 { + let slack_quarters = i64::from((visit.latest_minute - service_start).max(0) / 15); + i64::from(visit.priority.max(1)) * (slack_quarters + 1) +} + +fn mask_contains(available: i64, required: i64) -> bool { + (available & required) == required +} + +fn div_ceil(value: i64, divisor: i64) -> i64 { + if value <= 0 { + 0 + } else { + (value + divisor - 1) / divisor + } +} + +fn count_matches(value: i64) -> usize { + usize::try_from(value.max(0)).unwrap_or(usize::MAX) +} diff --git a/src/constraints/route_metrics_tests.rs b/src/constraints/route_metrics_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..5335f56bb7a11a7b08cc0952164acb1d1013f3f6 --- /dev/null +++ b/src/constraints/route_metrics_tests.rs @@ -0,0 +1,154 @@ +use super::route_metrics::{leg_for, route_stats}; +use crate::domain::{ + FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute, + TechnicianRouteInit, TravelLeg, TravelLegInit, +}; +use solverforge::ConstraintSet; + +#[test] +fn route_stats_accounts_for_travel_service_and_lateness() { + let plan = sample_plan(vec![0, 1]); + let stats = route_stats(&plan, &plan.technician_routes[0]); + + assert_eq!(stats.travel_seconds, 1_800); + assert_eq!(stats.service_minutes, 75); + assert_eq!(stats.late_minutes, 0); + assert_eq!(stats.route_minutes, 125); + assert_eq!(stats.overtime_minutes, 55); + assert_eq!(stats.valid_visits, 2); + assert_eq!(stats.scored_travel_legs, 3); + assert_eq!(stats.missing_skill_visits, 0); + assert_eq!(stats.missing_part_visits, 1); +} + +#[test] +fn travel_leg_lookup_prefers_row_major_contract() { + let plan = sample_plan(vec![0]); + let leg = leg_for(&plan, 0, 1).expect("leg should exist"); + + assert_eq!(leg.id, "leg-0-1"); + assert!(leg.reachable); +} + +#[test] +fn full_constraint_set_reports_expected_hard_penalties() { + let constraints = crate::constraints::create_constraints(); + let score = constraints.evaluate_all(&sample_plan(vec![0, 1])); + + assert_eq!(score.hard(), -56); + assert!(score.soft() < 0); +} + +#[test] +fn route_constraint_match_counts_describe_underlying_route_matches() { + let constraints = crate::constraints::create_constraints(); + let results = constraints.evaluate_each(&sample_plan(vec![0, 1])); + let match_count = |name: &str| { + results + .iter() + .find(|result| result.name == name) + .map(|result| result.match_count) + .unwrap_or_else(|| panic!("missing constraint result for {name}")) + }; + + assert_eq!(match_count("Balance Workload"), 1); + assert_eq!(match_count("Minimize Travel"), 3); + assert_eq!(match_count("Priority Slack"), 2); + assert_eq!(match_count("Required Parts"), 1); + assert_eq!(match_count("Shift Capacity"), 1); + assert_eq!(match_count("Territory Affinity"), 2); +} + +fn sample_plan(visits: Vec) -> FieldServicePlan { + let locations = vec![ + Location::new( + "loc-0", + "Hub", + "Hub".to_string(), + 45_700_000, + 9_670_000, + "depot".to_string(), + ), + Location::new( + "loc-1", + "Customer 1", + "Customer 1".to_string(), + 45_710_000, + 9_680_000, + "customer".to_string(), + ), + Location::new( + "loc-2", + "Customer 2", + "Customer 2".to_string(), + 45_720_000, + 9_690_000, + "customer".to_string(), + ), + ]; + let service_visits = vec![ + ServiceVisit::new(ServiceVisitInit { + id: "visit-0".to_string(), + name: "Boiler".to_string(), + customer: "Customer 1".to_string(), + location_idx: 1, + duration_minutes: 30, + earliest_minute: 510, + latest_minute: 540, + required_skill_mask: 0b001, + required_parts_mask: 0b010, + priority: 3, + territory: "center".to_string(), + }), + ServiceVisit::new(ServiceVisitInit { + id: "visit-1".to_string(), + name: "Lift".to_string(), + customer: "Customer 2".to_string(), + location_idx: 2, + duration_minutes: 45, + earliest_minute: 540, + latest_minute: 570, + required_skill_mask: 0b001, + required_parts_mask: 0b100, + priority: 2, + territory: "center".to_string(), + }), + ]; + let travel_legs = row_major_legs(3); + let mut route = TechnicianRoute::new(TechnicianRouteInit { + id: "route-0".to_string(), + technician_id: "tech-0".to_string(), + technician_name: "Ada".to_string(), + color: "#2563eb".to_string(), + start_location_idx: 0, + end_location_idx: 0, + shift_start_minute: 480, + shift_end_minute: 585, + max_route_minutes: 90, + skill_mask: 0b001, + inventory_mask: 0b010, + territory: "center".to_string(), + }); + route.visits = visits; + + FieldServicePlan::new(locations, service_visits, travel_legs, vec![route]) +} + +fn row_major_legs(width: usize) -> Vec { + (0..width) + .flat_map(|from| { + (0..width).map(move |to| { + let same = from == to; + TravelLeg::new(TravelLegInit { + id: format!("leg-{from}-{to}"), + name: format!("leg-{from}-{to}"), + from_location_idx: from, + to_location_idx: to, + duration_seconds: if same { 0 } else { 600 }, + distance_meters: if same { 0 } else { 2_000 }, + reachable: true, + }) + }) + }) + .collect() +} diff --git a/src/constraints/shift_capacity.rs b/src/constraints/shift_capacity.rs new file mode 100644 index 0000000000000000000000000000000000000000..7702725ea25a27d3254cf100e7190065aa7e6c88 --- /dev/null +++ b/src/constraints/shift_capacity.rs @@ -0,0 +1,17 @@ +use crate::constraints::route_metrics::{ + shift_capacity_match_count, shift_capacity_score, RouteConstraint, +}; +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: the complete route must fit inside the technician shift and route cap. +pub fn constraint() -> impl IncrementalConstraint { + RouteConstraint::new( + "Shift Capacity", + true, + HardSoftScore::of(1, 0), + shift_capacity_score, + shift_capacity_match_count, + ) +} diff --git a/src/constraints/territory_affinity.rs b/src/constraints/territory_affinity.rs new file mode 100644 index 0000000000000000000000000000000000000000..f5c89c3fcc429de04e6fced20cfc905cadd2f52b --- /dev/null +++ b/src/constraints/territory_affinity.rs @@ -0,0 +1,17 @@ +use crate::constraints::route_metrics::{ + territory_affinity_match_count, territory_affinity_score, RouteConstraint, +}; +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// SOFT: prefer visits inside the technician's familiar territory. +pub fn constraint() -> impl IncrementalConstraint { + RouteConstraint::new( + "Territory Affinity", + false, + HardSoftScore::of(0, 1), + territory_affinity_score, + territory_affinity_match_count, + ) +} diff --git a/src/constraints/time_windows.rs b/src/constraints/time_windows.rs new file mode 100644 index 0000000000000000000000000000000000000000..2596b712581d8b7d82eed05ff46ac141699ecebc --- /dev/null +++ b/src/constraints/time_windows.rs @@ -0,0 +1,17 @@ +use crate::constraints::route_metrics::{ + time_windows_match_count, time_windows_score, RouteConstraint, +}; +use crate::domain::FieldServicePlan; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: each visit must start no later than its latest service minute. +pub fn constraint() -> impl IncrementalConstraint { + RouteConstraint::new( + "Time Windows", + true, + HardSoftScore::of(1, 0), + time_windows_score, + time_windows_match_count, + ) +} diff --git a/src/data/bergamo_catalog.rs b/src/data/bergamo_catalog.rs new file mode 100644 index 0000000000000000000000000000000000000000..68786112a4d5b5be8432f1f21b81fce82366166f --- /dev/null +++ b/src/data/bergamo_catalog.rs @@ -0,0 +1,60 @@ +use crate::domain::Location; + +#[derive(Clone, Copy)] +pub(super) struct LocationSeed { + pub id: &'static str, + pub label: &'static str, + pub lat: f64, + pub lng: f64, + pub territory: &'static str, +} + +impl LocationSeed { + pub(super) fn to_location(self, kind: &'static str) -> Location { + Location::new( + self.id, + self.label, + self.label.to_string(), + coord_e6(self.lat), + coord_e6(self.lng), + kind.to_string(), + ) + } +} + +#[derive(Clone, Copy)] +pub(super) struct VisitProfile { + pub name: &'static str, + pub duration_minutes: i32, + pub earliest_minute: i32, + pub latest_minute: i32, + pub required_skill_mask: i64, + pub required_parts_mask: i64, + pub priority: i32, +} + +#[derive(Clone, Copy)] +pub(super) struct TechnicianSeed { + pub id: &'static str, + pub name: &'static str, + pub color: &'static str, + pub start_location_idx: usize, + pub end_location_idx: usize, + pub skill_mask: i64, + pub inventory_mask: i64, + pub territory: &'static str, +} + +pub(super) const SKILL_HVAC: i64 = 0b0001; +pub(super) const SKILL_ELECTRICAL: i64 = 0b0010; +pub(super) const SKILL_PLUMBING: i64 = 0b0100; +pub(super) const SKILL_ELEVATOR: i64 = 0b1000; + +pub(super) const PART_FILTERS: i64 = 0b0001; +pub(super) const PART_RELAYS: i64 = 0b0010; +pub(super) const PART_VALVES: i64 = 0b0100; +pub(super) const PART_SENSORS: i64 = 0b1000; + +fn coord_e6(value: f64) -> i32 { + (value * 1_000_000.0).round() as i32 +} diff --git a/src/data/bergamo_locations.rs b/src/data/bergamo_locations.rs new file mode 100644 index 0000000000000000000000000000000000000000..7f2e3547f9c9da960f58734f570959e33530ab3d --- /dev/null +++ b/src/data/bergamo_locations.rs @@ -0,0 +1,194 @@ +//! Static Bergamo depots and customer sites used by the `STANDARD` dataset. +//! +//! These are input facts, not solver decisions. SolverForge later changes only +//! the visit order inside technician routes. + +use super::bergamo_catalog::LocationSeed; + +pub(super) const DEPOTS: &[LocationSeed] = &[ + LocationSeed { + id: "depot-ops", + label: "Bergamo Operations Hub", + lat: 45.6954, + lng: 9.6703, + territory: "center", + }, + LocationSeed { + id: "depot-east", + label: "Seriate Parts Locker", + lat: 45.6835, + lng: 9.7210, + territory: "east", + }, +]; + +pub(super) const SERVICE_LOCATIONS: &[LocationSeed] = &[ + LocationSeed { + id: "loc-citta-alta", + label: "Citta Alta heating fault", + lat: 45.7036, + lng: 9.6627, + territory: "north", + }, + LocationSeed { + id: "loc-borgo-palazzo", + label: "Borgo Palazzo refrigeration", + lat: 45.6903, + lng: 9.6909, + territory: "east", + }, + LocationSeed { + id: "loc-stazione", + label: "Station kiosk power", + lat: 45.6900, + lng: 9.6750, + territory: "center", + }, + LocationSeed { + id: "loc-longuelo", + label: "Longuelo pump service", + lat: 45.6982, + lng: 9.6377, + territory: "west", + }, + LocationSeed { + id: "loc-redona", + label: "Redona lift inspection", + lat: 45.7107, + lng: 9.6999, + territory: "north", + }, + LocationSeed { + id: "loc-celadina", + label: "Celadina controls alarm", + lat: 45.6815, + lng: 9.7056, + territory: "east", + }, + LocationSeed { + id: "loc-valtesse", + label: "Valtesse boiler reset", + lat: 45.7202, + lng: 9.6736, + territory: "north", + }, + LocationSeed { + id: "loc-colognola", + label: "Colognola valve leak", + lat: 45.6767, + lng: 9.6469, + territory: "south", + }, + LocationSeed { + id: "loc-malpensata", + label: "Malpensata sensor swap", + lat: 45.6840, + lng: 9.6687, + territory: "south", + }, + LocationSeed { + id: "loc-seriate", + label: "Seriate medical cooler", + lat: 45.6856, + lng: 9.7242, + territory: "east", + }, + LocationSeed { + id: "loc-gorle", + label: "Gorle access control", + lat: 45.7014, + lng: 9.7138, + territory: "east", + }, + LocationSeed { + id: "loc-treviglio-road", + label: "Azzano workshop air unit", + lat: 45.6579, + lng: 9.6734, + territory: "south", + }, + LocationSeed { + id: "loc-monterosso", + label: "Monterosso lift callout", + lat: 45.7161, + lng: 9.6905, + territory: "north", + }, + LocationSeed { + id: "loc-loreto", + label: "Loreto electrical board", + lat: 45.6995, + lng: 9.6517, + territory: "west", + }, + LocationSeed { + id: "loc-stezzano", + label: "Stezzano retail HVAC", + lat: 45.6508, + lng: 9.6534, + territory: "south", + }, + LocationSeed { + id: "loc-grumello", + label: "Grumello pressure issue", + lat: 45.6888, + lng: 9.6275, + territory: "west", + }, + LocationSeed { + id: "loc-orio", + label: "Orio terminal chiller", + lat: 45.6689, + lng: 9.7044, + territory: "south", + }, + LocationSeed { + id: "loc-ranica", + label: "Ranica municipal lift", + lat: 45.7241, + lng: 9.7133, + territory: "north", + }, + LocationSeed { + id: "loc-torre-boldone", + label: "Torre Boldone boiler", + lat: 45.7178, + lng: 9.7075, + territory: "north", + }, + LocationSeed { + id: "loc-villaggio-sposi", + label: "Villaggio Sposi pump", + lat: 45.6901, + lng: 9.6365, + territory: "west", + }, + LocationSeed { + id: "loc-dalmine", + label: "Dalmine line sensor", + lat: 45.6482, + lng: 9.6061, + territory: "west", + }, + LocationSeed { + id: "loc-alzano", + label: "Alzano Lombardo relay", + lat: 45.7362, + lng: 9.7271, + territory: "north", + }, + LocationSeed { + id: "loc-ponte-san-pietro", + label: "Ponte San Pietro valve", + lat: 45.7001, + lng: 9.5908, + territory: "west", + }, + LocationSeed { + id: "loc-scanzo", + label: "Scanzorosciate cooler", + lat: 45.7105, + lng: 9.7354, + territory: "east", + }, +]; diff --git a/src/data/bergamo_profiles.rs b/src/data/bergamo_profiles.rs new file mode 100644 index 0000000000000000000000000000000000000000..7c55c1c9468a426ec7b9ad5c1eb68db5fb66f466 --- /dev/null +++ b/src/data/bergamo_profiles.rs @@ -0,0 +1,61 @@ +use super::bergamo_catalog::{ + VisitProfile, PART_RELAYS, PART_SENSORS, PART_VALVES, SKILL_ELECTRICAL, SKILL_ELEVATOR, + SKILL_HVAC, SKILL_PLUMBING, +}; + +pub(super) const VISIT_PROFILES: &[VisitProfile] = &[ + VisitProfile { + name: "Boiler restart", + duration_minutes: 35, + earliest_minute: 8 * 60, + latest_minute: 18 * 60, + required_skill_mask: SKILL_HVAC, + required_parts_mask: PART_SENSORS, + priority: 4, + }, + VisitProfile { + name: "Refrigeration diagnosis", + duration_minutes: 45, + earliest_minute: 9 * 60, + latest_minute: 18 * 60, + required_skill_mask: SKILL_HVAC | SKILL_ELECTRICAL, + required_parts_mask: PART_RELAYS, + priority: 5, + }, + VisitProfile { + name: "Electrical board check", + duration_minutes: 30, + earliest_minute: 8 * 60 + 30, + latest_minute: 18 * 60, + required_skill_mask: SKILL_ELECTRICAL, + required_parts_mask: PART_RELAYS, + priority: 3, + }, + VisitProfile { + name: "Pump service", + duration_minutes: 50, + earliest_minute: 10 * 60, + latest_minute: 18 * 60, + required_skill_mask: SKILL_PLUMBING, + required_parts_mask: PART_VALVES, + priority: 3, + }, + VisitProfile { + name: "Lift safety inspection", + duration_minutes: 60, + earliest_minute: 11 * 60, + latest_minute: 18 * 60, + required_skill_mask: SKILL_ELEVATOR | SKILL_ELECTRICAL, + required_parts_mask: PART_SENSORS, + priority: 4, + }, + VisitProfile { + name: "Controls alarm reset", + duration_minutes: 25, + earliest_minute: 13 * 60, + latest_minute: 18 * 60, + required_skill_mask: SKILL_ELECTRICAL, + required_parts_mask: PART_SENSORS, + priority: 2, + }, +]; diff --git a/src/data/bergamo_technicians.rs b/src/data/bergamo_technicians.rs new file mode 100644 index 0000000000000000000000000000000000000000..a20c1da944a7c2dac9ed82e715ea71b975a5323f --- /dev/null +++ b/src/data/bergamo_technicians.rs @@ -0,0 +1,70 @@ +use super::bergamo_catalog::{ + TechnicianSeed, PART_FILTERS, PART_RELAYS, PART_SENSORS, PART_VALVES, SKILL_ELECTRICAL, + SKILL_ELEVATOR, SKILL_HVAC, SKILL_PLUMBING, +}; + +pub(super) const TECHNICIANS: &[TechnicianSeed] = &[ + TechnicianSeed { + id: "tech-ada", + name: "Ada Romano", + color: "#2563eb", + start_location_idx: 0, + end_location_idx: 0, + skill_mask: ALL_SKILLS, + inventory_mask: ALL_PARTS, + territory: "center", + }, + TechnicianSeed { + id: "tech-marco", + name: "Marco Bianchi", + color: "#059669", + start_location_idx: 1, + end_location_idx: 1, + skill_mask: ALL_SKILLS, + inventory_mask: ALL_PARTS, + territory: "east", + }, + TechnicianSeed { + id: "tech-elena", + name: "Elena Conti", + color: "#d97706", + start_location_idx: 0, + end_location_idx: 0, + skill_mask: ALL_SKILLS, + inventory_mask: ALL_PARTS, + territory: "north", + }, + TechnicianSeed { + id: "tech-paolo", + name: "Paolo Gatti", + color: "#be123c", + start_location_idx: 0, + end_location_idx: 0, + skill_mask: ALL_SKILLS, + inventory_mask: ALL_PARTS, + territory: "west", + }, + TechnicianSeed { + id: "tech-sara", + name: "Sara Ferri", + color: "#7c3aed", + start_location_idx: 1, + end_location_idx: 1, + skill_mask: ALL_SKILLS, + inventory_mask: ALL_PARTS, + territory: "south", + }, + TechnicianSeed { + id: "tech-luca", + name: "Luca Moretti", + color: "#0f766e", + start_location_idx: 0, + end_location_idx: 0, + skill_mask: ALL_SKILLS, + inventory_mask: ALL_PARTS, + territory: "east", + }, +]; + +const ALL_SKILLS: i64 = SKILL_ELECTRICAL | SKILL_ELEVATOR | SKILL_HVAC | SKILL_PLUMBING; +const ALL_PARTS: i64 = PART_FILTERS | PART_RELAYS | PART_SENSORS | PART_VALVES; diff --git a/src/data/data_seed.rs b/src/data/data_seed.rs new file mode 100644 index 0000000000000000000000000000000000000000..1c2c36988558b707e20a3c1e5186b89ba40696e6 --- /dev/null +++ b/src/data/data_seed.rs @@ -0,0 +1,292 @@ +//! Deterministic Bergamo demo-data builder and routing preparation. +//! +//! The public app starts from ordinary domain facts: locations, service visits, +//! technician routes, and travel legs. Road-network preparation enriches those +//! facts before solving, but the solver still receives a normal +//! `FieldServicePlan`. + +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; +use std::time::Duration; + +use solverforge_maps::{ + BoundingBox, Coord, NetworkConfig, NetworkRef, RoadNetwork, RoutingError, UNREACHABLE, +}; + +use super::bergamo_locations::{DEPOTS, SERVICE_LOCATIONS}; +use super::bergamo_profiles::VISIT_PROFILES; +use super::bergamo_technicians::TECHNICIANS; +use crate::domain::{ + FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute, + TechnicianRouteInit, TravelLeg, TravelLegInit, +}; + +const BERGAMO_BBOX: BoundingBox = BoundingBox { + min_lat: 45.64, + min_lng: 9.58, + max_lat: 45.75, + max_lng: 9.78, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DemoData { + Standard, +} + +#[derive(Debug)] +pub enum DemoDataError { + Routing(RoutingError), +} + +impl fmt::Display for DemoDataError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Routing(error) => write!(f, "Bergamo OSM routing data is unavailable: {error}"), + } + } +} + +impl std::error::Error for DemoDataError {} + +impl From for DemoDataError { + fn from(error: RoutingError) -> Self { + Self::Routing(error) + } +} + +const AVAILABLE_DEMO_DATA: &[DemoData] = &[DemoData::Standard]; +const DEFAULT_DEMO_DATA: DemoData = DemoData::Standard; + +pub fn default_demo_data() -> DemoData { + DEFAULT_DEMO_DATA +} + +/// Returns the complete list of public demo ids exposed through `/demo-data`. +pub fn available_demo_data() -> &'static [DemoData] { + AVAILABLE_DEMO_DATA +} + +impl DemoData { + pub fn id(self) -> &'static str { + match self { + DemoData::Standard => "STANDARD", + } + } + + pub fn default_demo_data() -> Self { + default_demo_data() + } + + pub fn available_demo_data() -> &'static [Self] { + available_demo_data() + } + + fn technician_count(self) -> usize { + match self { + Self::Standard => 6, + } + } + + fn visit_count(self) -> usize { + match self { + Self::Standard => SERVICE_LOCATIONS.len() * 2, + } + } +} + +impl FromStr for DemoData { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.to_ascii_uppercase().as_str() { + "STANDARD" => Ok(DemoData::Standard), + _ => Err(()), + } + } +} + +/// Builds the requested demo plan and prepares road-network travel facts. +pub async fn generate(demo: DemoData) -> Result { + // The initial demo response must be fast and deterministic, so it ships only + // seed self-legs. Full road-network legs are prepared when a solve starts. + let locations = build_locations(demo); + let travel_legs = build_seed_travel_legs(locations.len()); + let service_visits = build_service_visits(demo); + let technician_routes = build_technician_routes(demo); + + Ok(FieldServicePlan::new( + locations, + service_visits, + travel_legs, + technician_routes, + )) +} + +/// Replaces seed travel legs with road-network durations and distances. +pub async fn prepare_routing(plan: &mut FieldServicePlan) -> Result<(), DemoDataError> { + // This is the expensive OSM-backed step. It runs once per submitted plan so + // every candidate route move is scored against a stable travel matrix. + let coords = plan + .locations + .iter() + .map(|location| Coord::new(location.lat(), location.lng())) + .collect::>(); + let network = load_network().await?; + let matrix = network.compute_matrix(&coords, None).await; + plan.travel_legs = build_travel_legs(&matrix, coords.len()); + Ok(()) +} + +pub async fn load_network() -> Result { + RoadNetwork::load_or_fetch(&BERGAMO_BBOX, &network_config(), None) + .await + .map_err(DemoDataError::from) +} + +pub fn network_config() -> NetworkConfig { + NetworkConfig::default() + .cache_dir(PathBuf::from(".osm_cache/field-service-routing/bergamo")) + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(30)) + .overpass_max_retries(1) + .overpass_retry_backoff(Duration::from_secs(2)) +} + +fn build_locations(demo: DemoData) -> Vec { + let service_location_count = demo.visit_count().min(SERVICE_LOCATIONS.len()); + + DEPOTS + .iter() + .map(|seed| seed.to_location("depot")) + .chain( + SERVICE_LOCATIONS + .iter() + .take(service_location_count) + .map(|seed| seed.to_location("customer")), + ) + .collect() +} + +fn build_service_visits(demo: DemoData) -> Vec { + (0..demo.visit_count()) + .map(|idx| { + let seed = &SERVICE_LOCATIONS[idx % SERVICE_LOCATIONS.len()]; + let profile = VISIT_PROFILES[idx % VISIT_PROFILES.len()]; + ServiceVisit::new(ServiceVisitInit { + id: format!("visit-{idx:02}"), + name: profile.name.to_string(), + customer: seed.label.to_string(), + location_idx: DEPOTS.len() + (idx % SERVICE_LOCATIONS.len()), + duration_minutes: profile.duration_minutes, + earliest_minute: profile.earliest_minute, + latest_minute: profile.latest_minute, + required_skill_mask: profile.required_skill_mask, + required_parts_mask: profile.required_parts_mask, + priority: profile.priority, + territory: seed.territory.to_string(), + }) + }) + .collect() +} + +fn build_technician_routes(demo: DemoData) -> Vec { + TECHNICIANS + .iter() + .take(demo.technician_count()) + .enumerate() + .map(|(idx, seed)| { + TechnicianRoute::new(TechnicianRouteInit { + id: format!("route-{idx:02}"), + technician_id: seed.id.to_string(), + technician_name: seed.name.to_string(), + color: seed.color.to_string(), + start_location_idx: seed.start_location_idx, + end_location_idx: seed.end_location_idx, + shift_start_minute: 8 * 60, + shift_end_minute: 18 * 60, + max_route_minutes: 10 * 60, + skill_mask: seed.skill_mask, + inventory_mask: seed.inventory_mask, + territory: seed.territory.to_string(), + }) + }) + .collect() +} + +fn build_seed_travel_legs(width: usize) -> Vec { + (0..width) + .map(|idx| { + TravelLeg::new(TravelLegInit { + id: format!("leg-{idx:02}-{idx:02}"), + name: format!("leg-{idx:02}-{idx:02}"), + from_location_idx: idx, + to_location_idx: idx, + duration_seconds: 0, + distance_meters: 0, + reachable: true, + }) + }) + .collect() +} + +fn build_travel_legs(matrix: &solverforge_maps::TravelTimeMatrix, width: usize) -> Vec { + let mut legs = Vec::with_capacity(width * width); + + for from in 0..width { + for to in 0..width { + let (duration_seconds, distance_meters, reachable) = if from == to { + (0, 0, true) + } else { + let matrix_duration = matrix.get(from, to).unwrap_or(UNREACHABLE); + let matrix_distance = matrix.distance_meters(from, to).unwrap_or(UNREACHABLE); + if matrix_duration == UNREACHABLE || matrix_distance == UNREACHABLE { + (0, 0, false) + } else { + (matrix_duration, matrix_distance, true) + } + }; + + legs.push(TravelLeg::new(TravelLegInit { + id: format!("leg-{from:02}-{to:02}"), + name: format!("leg-{from:02}-{to:02}"), + from_location_idx: from, + to_location_idx: to, + duration_seconds, + distance_meters, + reachable, + })); + } + } + + legs +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_technician_routes_start_without_assigned_visits() { + for demo in DemoData::available_demo_data() { + let routes = build_technician_routes(*demo); + + assert!(!routes.is_empty()); + assert!(routes.iter().all(|route| route.visits.is_empty())); + } + } + + #[tokio::test] + async fn generated_seed_plan_has_only_identity_travel_legs() { + let plan = generate(DemoData::Standard).await.unwrap(); + + assert_eq!(plan.travel_legs.len(), plan.locations.len()); + assert!(plan.travel_legs.iter().enumerate().all(|(idx, leg)| { + leg.from_location_idx == idx + && leg.to_location_idx == idx + && leg.duration_seconds == 0 + && leg.distance_meters == 0 + && leg.reachable + })); + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..ef7f0960f658b41c246acb0d1f8f06c2693007fb --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,15 @@ +//! Stable demo-data boundary for the FSR app. +//! +//! Other layers import from `crate::data` instead of city-specific files. That +//! keeps routing preparation and demo-id parsing behind one small interface. + +mod bergamo_catalog; +mod bergamo_locations; +mod bergamo_profiles; +mod bergamo_technicians; +mod data_seed; + +pub use data_seed::{ + available_demo_data, default_demo_data, generate, load_network, prepare_routing, DemoData, + DemoDataError, +}; diff --git a/src/domain/field_service_plan.rs b/src/domain/field_service_plan.rs new file mode 100644 index 0000000000000000000000000000000000000000..654728eaaa3221a5e151983be74551df5cb37804 --- /dev/null +++ b/src/domain/field_service_plan.rs @@ -0,0 +1,67 @@ +//! Planning solution for the field-service routing problem. +//! +//! `FieldServicePlan` is both the input to SolverForge and the domain value +//! converted to JSON snapshots after solving. Facts stay read-only; technician +//! routes carry the mutable visit list. + +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +// @solverforge:begin solution-imports +use super::Location; +use super::ServiceVisit; +use super::TechnicianRoute; +use super::TravelLeg; +// @solverforge:end solution-imports + +/// Full planning solution passed to the SolverForge runtime and HTTP API. +/// +/// The first three collections are read-only facts. `technician_routes` is the +/// planning entity collection because each route owns the mutable visit list. +#[planning_solution( + constraints = "crate::constraints::create_constraints", + solver_toml = "../../solver.toml" +)] +#[derive(Serialize, Deserialize)] +pub struct FieldServicePlan { + // @solverforge:begin solution-collections + /// All depots and customer sites, addressed by vector index from visits and + /// route endpoints. + #[problem_fact_collection] + pub locations: Vec, + /// Customer jobs that must be inserted into technician routes. + #[problem_fact_collection] + pub service_visits: Vec, + /// Directed travel matrix used by constraints and route geometry. + #[problem_fact_collection] + pub travel_legs: Vec, + /// Route entities whose `visits` lists are changed by the solver. + #[planning_entity_collection] + pub technician_routes: Vec, + // @solverforge:end solution-collections + #[planning_score] + pub score: Option, +} + +impl FieldServicePlan { + /// Builds a plan from immutable facts and initially empty route entities. + #[rustfmt::skip] + pub fn new( + // @solverforge:begin solution-constructor-params + locations: Vec, + service_visits: Vec, + travel_legs: Vec, + technician_routes: Vec, + // @solverforge:end solution-constructor-params + ) -> Self { + Self { + // @solverforge:begin solution-constructor-init + locations, + service_visits, + travel_legs, + technician_routes, + // @solverforge:end solution-constructor-init + score: None, + } + } +} diff --git a/src/domain/location.rs b/src/domain/location.rs new file mode 100644 index 0000000000000000000000000000000000000000..ab3c908a71a5167323d3803597e32a88a0665753 --- /dev/null +++ b/src/domain/location.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +/// Depot or customer site used by the routing model. +/// +/// SolverForge treats a `Location` as read-only problem data. Routes refer to +/// locations by vector index so constraints and map rendering can cheaply look +/// up coordinates without copying place records into every visit. +#[problem_fact] +#[derive(Serialize, Deserialize)] +pub struct Location { + #[planning_id] + pub id: String, + pub name: String, + pub label: String, + pub lat_e6: i32, + pub lng_e6: i32, + pub kind: String, +} + +impl Location { + /// Builds one location fact from seed data or transport input. + pub fn new( + id: impl Into, + name: impl Into, + label: String, + lat_e6: i32, + lng_e6: i32, + kind: String, + ) -> Self { + Self { + id: id.into(), + name: name.into(), + label, + lat_e6, + lng_e6, + kind, + } + } + + /// Returns latitude in degrees from the integer microdegree storage format. + pub fn lat(&self) -> f64 { + f64::from(self.lat_e6) / 1_000_000.0 + } + + /// Returns longitude in degrees from the integer microdegree storage format. + pub fn lng(&self) -> f64 { + f64::from(self.lng_e6) / 1_000_000.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_location_construction() { + let fact = Location::new( + "test-id", + "test", + "test".to_string(), + 0, + 0, + "test".to_string(), + ); + assert_eq!(fact.id, "test-id"); + assert_eq!(fact.name, "test"); + let _ = &fact.label; + let _ = &fact.lat_e6; + let _ = &fact.lng_e6; + let _ = &fact.kind; + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e4b78bdcad649741b4f9606a421595a7066acc3 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,26 @@ +//! Planning-model manifest and domain-layer exports. +//! +//! `planning_model!` is the SolverForge boundary for this app. Keep the exports +//! in the same conceptual order as `solverforge.app.toml`: facts, planning +//! entity, then solution. + +solverforge::planning_model! { + root = "src/domain"; + + // @solverforge:begin domain-exports + mod location; + mod service_visit; + mod travel_leg; + mod technician_route; + mod field_service_plan; + + pub use location::Location; + pub use service_visit::ServiceVisit; + pub use service_visit::ServiceVisitInit; + pub use travel_leg::TravelLeg; + pub use travel_leg::TravelLegInit; + pub use technician_route::TechnicianRoute; + pub use technician_route::TechnicianRouteInit; + pub use field_service_plan::FieldServicePlan; + // @solverforge:end domain-exports +} diff --git a/src/domain/service_visit.rs b/src/domain/service_visit.rs new file mode 100644 index 0000000000000000000000000000000000000000..d078e48cce8c48bae1ea9cfb81d6a3ba3ce831f6 --- /dev/null +++ b/src/domain/service_visit.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +/// Customer job that must be inserted into exactly one technician route. +/// +/// This is problem data, not a planning entity. The solver does not mutate the +/// visit itself; it mutates `TechnicianRoute.visits`, which stores indexes into +/// the `FieldServicePlan.service_visits` vector. +#[problem_fact] +#[derive(Serialize, Deserialize)] +pub struct ServiceVisit { + #[planning_id] + pub id: String, + pub name: String, + pub customer: String, + pub location_idx: usize, + pub duration_minutes: i32, + pub earliest_minute: i32, + pub latest_minute: i32, + pub required_skill_mask: i64, + pub required_parts_mask: i64, + pub priority: i32, + pub territory: String, +} + +/// Constructor payload for `ServiceVisit`. +/// +/// Keeping construction grouped avoids a long positional argument list where a +/// beginner could easily swap time windows, masks, or location indexes. +#[derive(Debug, Clone)] +pub struct ServiceVisitInit { + pub id: String, + pub name: String, + pub customer: String, + pub location_idx: usize, + pub duration_minutes: i32, + pub earliest_minute: i32, + pub latest_minute: i32, + pub required_skill_mask: i64, + pub required_parts_mask: i64, + pub priority: i32, + pub territory: String, +} + +impl ServiceVisit { + /// Builds one immutable service-visit fact. + pub fn new(init: ServiceVisitInit) -> Self { + Self { + id: init.id, + name: init.name, + customer: init.customer, + location_idx: init.location_idx, + duration_minutes: init.duration_minutes, + earliest_minute: init.earliest_minute, + latest_minute: init.latest_minute, + required_skill_mask: init.required_skill_mask, + required_parts_mask: init.required_parts_mask, + priority: init.priority, + territory: init.territory, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_service_visit_construction() { + let fact = ServiceVisit::new(ServiceVisitInit { + id: "test-id".to_string(), + name: "test".to_string(), + customer: "test".to_string(), + location_idx: Default::default(), + duration_minutes: Default::default(), + earliest_minute: Default::default(), + latest_minute: Default::default(), + required_skill_mask: Default::default(), + required_parts_mask: Default::default(), + priority: Default::default(), + territory: "test".to_string(), + }); + assert_eq!(fact.id, "test-id"); + assert_eq!(fact.name, "test"); + let _ = &fact.customer; + let _ = &fact.location_idx; + let _ = &fact.duration_minutes; + let _ = &fact.earliest_minute; + let _ = &fact.latest_minute; + let _ = &fact.required_skill_mask; + let _ = &fact.required_parts_mask; + let _ = &fact.priority; + let _ = &fact.territory; + } +} diff --git a/src/domain/technician_route.rs b/src/domain/technician_route.rs new file mode 100644 index 0000000000000000000000000000000000000000..2c4bab3d6ea3b2ba6a8206fdb539dec22dd195ed --- /dev/null +++ b/src/domain/technician_route.rs @@ -0,0 +1,112 @@ +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +/// One technician's route, including the visit order SolverForge is allowed to change. +/// +/// A `TechnicianRoute` is the planning entity in this app. Its descriptive +/// fields are fixed input data for the technician, while `visits` is the list +/// planning variable that local search reorders and moves between routes. +#[planning_entity] +#[derive(Serialize, Deserialize)] +pub struct TechnicianRoute { + #[planning_id] + pub id: String, + pub technician_id: String, + pub technician_name: String, + pub color: String, + pub start_location_idx: usize, + pub end_location_idx: usize, + pub shift_start_minute: i32, + pub shift_end_minute: i32, + pub max_route_minutes: i32, + pub skill_mask: i64, + pub inventory_mask: i64, + pub territory: String, + // SolverForge mutates this vector. Each value is an index into + // `FieldServicePlan.service_visits`, not a copied `ServiceVisit`. + // @solverforge:begin entity-variables + #[planning_list_variable(element_collection = "service_visits")] + pub visits: Vec, + // @solverforge:end entity-variables +} + +/// Constructor payload for `TechnicianRoute`. +/// +/// Grouping the technician attributes keeps call sites readable and makes the +/// immutable technician data visually separate from the mutable route list. +#[derive(Debug, Clone)] +pub struct TechnicianRouteInit { + pub id: String, + pub technician_id: String, + pub technician_name: String, + pub color: String, + pub start_location_idx: usize, + pub end_location_idx: usize, + pub shift_start_minute: i32, + pub shift_end_minute: i32, + pub max_route_minutes: i32, + pub skill_mask: i64, + pub inventory_mask: i64, + pub territory: String, +} + +impl TechnicianRoute { + /// Builds an empty route for one technician. + /// + /// The list variable starts empty so construction heuristics can choose the + /// first assignment instead of inheriting a hand-written visit order. + pub fn new(init: TechnicianRouteInit) -> Self { + Self { + id: init.id, + technician_id: init.technician_id, + technician_name: init.technician_name, + color: init.color, + start_location_idx: init.start_location_idx, + end_location_idx: init.end_location_idx, + shift_start_minute: init.shift_start_minute, + shift_end_minute: init.shift_end_minute, + max_route_minutes: init.max_route_minutes, + skill_mask: init.skill_mask, + inventory_mask: init.inventory_mask, + territory: init.territory, + // @solverforge:begin entity-variable-init + visits: Vec::new(), + // @solverforge:end entity-variable-init + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_technician_route_construction() { + let entity = TechnicianRoute::new(TechnicianRouteInit { + id: "test-id".to_string(), + technician_id: "test".to_string(), + technician_name: "test".to_string(), + color: "test".to_string(), + start_location_idx: Default::default(), + end_location_idx: Default::default(), + shift_start_minute: Default::default(), + shift_end_minute: Default::default(), + max_route_minutes: Default::default(), + skill_mask: Default::default(), + inventory_mask: Default::default(), + territory: "test".to_string(), + }); + assert_eq!(entity.id, "test-id"); + let _ = &entity.technician_id; + let _ = &entity.technician_name; + let _ = &entity.color; + let _ = &entity.start_location_idx; + let _ = &entity.end_location_idx; + let _ = &entity.shift_start_minute; + let _ = &entity.shift_end_minute; + let _ = &entity.max_route_minutes; + let _ = &entity.skill_mask; + let _ = &entity.inventory_mask; + let _ = &entity.territory; + } +} diff --git a/src/domain/travel_leg.rs b/src/domain/travel_leg.rs new file mode 100644 index 0000000000000000000000000000000000000000..915188fc4d9c1faf8c81e4b2ee035a8cd882cda0 --- /dev/null +++ b/src/domain/travel_leg.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +/// Precomputed travel fact between two locations. +/// +/// Constraints read these facts while scoring a route. Keeping travel as problem +/// data makes scoring deterministic: the solver evaluates candidate visit +/// orders against the matrix already attached to the plan instead of calling the +/// map service during every move. +#[problem_fact] +#[derive(Serialize, Deserialize)] +pub struct TravelLeg { + #[planning_id] + pub id: String, + pub name: String, + pub from_location_idx: usize, + pub to_location_idx: usize, + pub duration_seconds: i64, + pub distance_meters: i64, + pub reachable: bool, +} + +/// Constructor payload for `TravelLeg`. +/// +/// The route matrix has many similar numeric fields, so named initialization is +/// easier to audit than positional arguments. +#[derive(Debug, Clone)] +pub struct TravelLegInit { + pub id: String, + pub name: String, + pub from_location_idx: usize, + pub to_location_idx: usize, + pub duration_seconds: i64, + pub distance_meters: i64, + pub reachable: bool, +} + +impl TravelLeg { + /// Builds one directed matrix entry from `from_location_idx` to `to_location_idx`. + pub fn new(init: TravelLegInit) -> Self { + Self { + id: init.id, + name: init.name, + from_location_idx: init.from_location_idx, + to_location_idx: init.to_location_idx, + duration_seconds: init.duration_seconds, + distance_meters: init.distance_meters, + reachable: init.reachable, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_travel_leg_construction() { + let fact = TravelLeg::new(TravelLegInit { + id: "test-id".to_string(), + name: "test".to_string(), + from_location_idx: Default::default(), + to_location_idx: Default::default(), + duration_seconds: Default::default(), + distance_meters: Default::default(), + reachable: false, + }); + assert_eq!(fact.id, "test-id"); + assert_eq!(fact.name, "test"); + let _ = &fact.from_location_idx; + let _ = &fact.to_location_idx; + let _ = &fact.duration_seconds; + let _ = &fact.distance_meters; + let _ = &fact.reachable; + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..d0a52a53f6a9ad37f91f5bc94a229a27b3fb0455 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +//! SolverForge field-service routing application. +//! +//! The crate follows the same teaching shape as the other use cases: `domain` +//! defines the planning model, `constraints` defines scoring, `data` builds the +//! deterministic Bergamo instance, `solver` owns retained runtime jobs, and +//! `api` exposes the browser-facing HTTP surface. + +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..a67d0d7ba5094c8742bcc40b59d55aacd676e03b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,73 @@ +//! Axum entrypoint for the field-service routing app. +//! +//! The binary serves stock SolverForge UI assets, this app's static files, and +//! the retained-job API from one process so the Docker Space only needs one +//! `PORT` binding. + +use solverforge_fsr::api; + +use std::net::SocketAddr; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; + +#[tokio::main] +async fn main() { + // Use the stock SolverForge console logger so solve progress appears in + // local runs and Space container logs. + solverforge::console::init(); + + let state = Arc::new(api::AppState::new()); + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = api::router(state) + .merge(solverforge_ui::routes()) + .fallback_service(ServeDir::new("static")) + .layer(cors); + + // Hugging Face Spaces inject `PORT`; 7860 remains the local default used in + // docs, tests, and the Makefile. + let port = std::env::var("PORT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(7860); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + println!("โ–ธ solverforge-fsr listening on http://{}", addr); + println!("โ–ธ Open http://localhost:{} in your browser\n", port); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .unwrap(); +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl-C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + println!("โ–ธ solverforge-fsr shutting down"); +} diff --git a/src/solver/event_payload.rs b/src/solver/event_payload.rs new file mode 100644 index 0000000000000000000000000000000000000000..c4e3e70a298844b7c1b8cbabfc49b6608d0f3a88 --- /dev/null +++ b/src/solver/event_payload.rs @@ -0,0 +1,205 @@ +//! JSON event payloads sent over the FSR SSE stream. +//! +//! SolverForge emits strongly typed lifecycle events. This module converts them +//! to the stable camelCase JSON shape consumed by the browser status bar and +//! route renderer. + +use serde::Serialize; +use std::time::Duration; + +use solverforge::{ + HardSoftScore, SolverEventMetadata, SolverLifecycleState, SolverSnapshot, SolverStatus, + SolverTelemetry, SolverTerminalReason, +}; + +use crate::api::PlanDto; +use crate::domain::FieldServicePlan; + +#[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, +} + +#[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, +} + +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, + }) +} + +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, + }) +} + +pub(super) fn event_payload( + job_id: usize, + event_type: &'static str, + metadata: &SolverEventMetadata, + solution: Option<&FieldServicePlan>, + 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), + }) +} + +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", + } +} + +pub(super) fn bootstrap_snapshot_event_type(state: SolverLifecycleState) -> &'static str { + match state { + SolverLifecycleState::Solving => "best_solution", + other => bootstrap_event_type(other), + } +} + +fn serialize_payload(payload: JobEventPayload) -> String { + serde_json::to_string(&payload).expect("failed to serialize solver lifecycle payload") +} + +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, + ), + } +} + +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", + } +} + +fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str { + match reason { + SolverTerminalReason::Completed => "completed", + SolverTerminalReason::TerminatedByConfig => "terminated_by_config", + SolverTerminalReason::Cancelled => "cancelled", + SolverTerminalReason::Failed => "failed", + } +} + +fn duration_to_millis(duration: Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + +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 + } +} + +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 + } +} diff --git a/src/solver/mod.rs b/src/solver/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..f53f4467e4e5b4ec6993cdbe4daa1298e7b5ae9d --- /dev/null +++ b/src/solver/mod.rs @@ -0,0 +1,10 @@ +//! Solver-runtime facade exports for the FSR app. +//! +//! Keeping the retained runtime behind `SolverService` prevents HTTP handlers +//! from depending directly on `SolverManager`. + +mod event_payload; +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..8e1b7edfd94e183c02a8ac99c6c4543aa0c82231 --- /dev/null +++ b/src/solver/service.rs @@ -0,0 +1,186 @@ +//! Retained-job orchestration for field-service solves. +//! +//! SolverForge owns search and scoring. This service owns app-level concerns: +//! registering SSE broadcasters, translating public string ids to runtime job +//! ids, and exposing pause/resume/cancel/delete to the API layer. + +use parking_lot::RwLock; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{broadcast, mpsc}; + +use solverforge::{ + HardSoftScore, SolverEvent, SolverManager, SolverManagerError, SolverSnapshot, + SolverSnapshotAnalysis, SolverStatus, +}; + +use super::event_payload::{ + bootstrap_event_type, bootstrap_snapshot_event_type, event_payload, + snapshot_status_event_payload, status_event_payload, +}; +use crate::domain::FieldServicePlan; + +// The retained runtime needs a manager with `'static` lifetime because jobs can +// continue after the HTTP handler that started them has returned. +static MANAGER: SolverManager = SolverManager::new(); + +struct JobState { + sse_tx: broadcast::Sender, +} + +/// Manages retained solving jobs and broadcasts lifecycle-complete SSE payloads. +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 retained solve and registers the SSE broadcaster for that job. + pub fn start_job(&self, plan: FieldServicePlan) -> 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 receive 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, + )) + } + + pub fn get_status(&self, id: &str) -> Result, SolverManagerError> { + let job_id = parse_job_id(id)?; + MANAGER.get_status(job_id) + } + + pub fn pause(&self, id: &str) -> Result<(), SolverManagerError> { + MANAGER.pause(parse_job_id(id)?) + } + + pub fn resume(&self, id: &str) -> Result<(), SolverManagerError> { + MANAGER.resume(parse_job_id(id)?) + } + + pub fn cancel(&self, id: &str) -> Result<(), SolverManagerError> { + MANAGER.cancel(parse_job_id(id)?) + } + + 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(()) + } + + pub fn get_snapshot( + &self, + id: &str, + snapshot_revision: Option, + ) -> Result, SolverManagerError> { + MANAGER.get_snapshot(parse_job_id(id)?, snapshot_revision) + } + + pub fn analyze_snapshot( + &self, + id: &str, + snapshot_revision: Option, + ) -> Result, SolverManagerError> { + MANAGER.analyze_snapshot(parse_job_id(id)?, snapshot_revision) + } +} + +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); + } +} + +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/static/app-dataset.js b/static/app-dataset.js new file mode 100644 index 0000000000000000000000000000000000000000..e4eb87e58f3d629394b3c552066dac455a39ef11 --- /dev/null +++ b/static/app-dataset.js @@ -0,0 +1,56 @@ +/* app-dataset.js - demo dataset loader for the Bergamo FSR demo */ + +(function () { + 'use strict'; + + var FSR = window.FSR = window.FSR || {}; + + FSR.createDemoDataController = function (options) { + var utils = options.utils; + var selectedId = 'STANDARD'; + var catalog = { defaultId: selectedId, availableIds: [selectedId] }; + var loading = false; + var el = document.createDocumentFragment(); + + return { + bootstrap: bootstrap, + el: el, + getCatalog: function () { return catalog; }, + getSelectedId: function () { return selectedId; }, + isLoading: function () { return loading; }, + resolvePlan: resolvePlan, + }; + + function bootstrap() { + setLoading(true); + notifyCatalog(); + return utils.fetchDemoPlan(selectedId) + .then(function (plan) { notifyPlan(plan); }) + .catch(function (err) { notifyError(err); }) + .finally(function () { setLoading(false); }); + } + + function resolvePlan() { + var currentPlan = options.getCurrentPlan(); + if (currentPlan) return Promise.resolve(utils.clonePlan(currentPlan)); + return utils.fetchDemoPlan(selectedId); + } + + function setLoading(nextLoading) { + loading = nextLoading; + if (options.onLoadingChange) options.onLoadingChange(loading); + } + + function notifyCatalog() { + if (options.onCatalog) options.onCatalog(catalog, selectedId); + } + + function notifyPlan(plan) { + if (options.onPlan) options.onPlan(plan, selectedId); + } + + function notifyError(err) { + if (options.onError) options.onError(err); + } + }; +})(); diff --git a/static/app-layout.js b/static/app-layout.js new file mode 100644 index 0000000000000000000000000000000000000000..a8ef2556d6259a100aa28852986aefb130bb306f --- /dev/null +++ b/static/app-layout.js @@ -0,0 +1,105 @@ +/* app-layout.js - application shell construction for the Bergamo FSR demo */ + +(function () { + 'use strict'; + + var FSR = window.FSR = window.FSR || {}; + + FSR.createAppLayout = function (options) { + var SF = options.SF; + var app = options.app; + var panels = { + map: SF.el('div', { className: 'sf-content' }), + routes: SF.el('div', { className: 'sf-content', style: { display: 'none' } }), + data: SF.el('div', { className: 'sf-content', style: { display: 'none' } }), + api: SF.el('div', { className: 'sf-content', style: { display: 'none' } }), + }; + + var header = SF.createHeader({ + logo: '/sf/img/ouroboros.svg', + title: options.config.title, + subtitle: options.config.subtitle, + tabs: [ + { id: 'map', label: 'Map', icon: 'fa-map-location-dot', active: true }, + { id: 'routes', label: 'Routes', icon: 'fa-list-ol' }, + { id: 'data', label: 'Data', icon: 'fa-table' }, + { id: 'api', label: 'REST API', icon: 'fa-book' }, + ], + actions: { + onSolve: options.onSolve, + onPause: options.onPause, + onResume: options.onResume, + onCancel: options.onCancel, + onAnalyze: options.onAnalyze, + }, + onTabChange: function (tab) { + Object.keys(panels).forEach(function (key) { + panels[key].style.display = key === tab ? '' : 'none'; + }); + if (tab === 'map' && options.onMapTabShown) options.onMapTabShown(); + }, + }); + + app.appendChild(header); + options.statusBar.bindHeader(header); + app.appendChild(options.statusBar.el); + + var bootstrapNotice = SF.el('div', { + className: 'sf-content', + style: { + display: 'none', + padding: '12px 16px', + marginBottom: '12px', + border: '1px solid #dc2626', + borderRadius: '8px', + background: '#fef2f2', + color: '#991b1b', + }, + }); + app.appendChild(bootstrapNotice); + + var summaryContainer = SF.el('div', { className: 'fsr-summary' }); + var mapShell = SF.el('div', { className: 'fsr-map-shell' }); + var routeListCard = SF.el('section', { className: 'sf-section fsr-routes-card' }); + var routeCards = SF.el('div', { className: 'fsr-route-list' }); + var mapCard = SF.el('section', { className: 'sf-section fsr-map-card' }); + var mapContainer = SF.el('div', { id: 'fsr-map', className: 'sf-map-container fsr-map' }); + + routeListCard.appendChild(SF.el('h3', null, 'Routes')); + routeListCard.appendChild(routeCards); + mapCard.appendChild(SF.el('h3', null, 'Map')); + mapCard.appendChild(mapContainer); + mapShell.appendChild(routeListCard); + mapShell.appendChild(mapCard); + panels.map.appendChild(summaryContainer); + panels.map.appendChild(mapShell); + + var timelineContainer = SF.el('div'); + var tablesContainer = SF.el('div'); + var apiGuideContainer = SF.el('div'); + panels.routes.appendChild(timelineContainer); + panels.data.appendChild(tablesContainer); + panels.api.appendChild(apiGuideContainer); + + app.appendChild(panels.map); + app.appendChild(panels.routes); + app.appendChild(panels.data); + app.appendChild(panels.api); + app.appendChild(SF.createFooter({ + links: [ + { label: 'SolverForge', url: 'https://www.solverforge.org' }, + { label: 'Docs', url: 'https://www.solverforge.org/docs' }, + ], + })); + + return { + apiGuideContainer: apiGuideContainer, + bootstrapNotice: bootstrapNotice, + header: header, + routeCards: routeCards, + summaryContainer: summaryContainer, + tablesContainer: tablesContainer, + timelineContainer: timelineContainer, + }; + }; +})(); diff --git a/static/app-render-map.js b/static/app-render-map.js new file mode 100644 index 0000000000000000000000000000000000000000..e2d3e0ded8cbe2045cca94ed16485614c18a470d --- /dev/null +++ b/static/app-render-map.js @@ -0,0 +1,165 @@ +/* app-render-map.js - Leaflet map rendering for the Bergamo FSR demo */ + +(function () { + 'use strict'; + + var FSR = window.FSR = window.FSR || {}; + var utils = FSR.utils; + + FSR.createMapRenderer = function (options) { + return { renderMap: renderMap }; + + function renderMap(plan, routeGeometry) { + if (!options.routeMap) return; + options.routeMap.clearAll(); + + var locations = plan.locations || []; + var visits = plan.service_visits || []; + var routes = plan.technician_routes || []; + var assigned = utils.assignedVisitSet(routes); + var focusedRouteId = options.getFocusedRouteId ? options.getFocusedRouteId() : null; + var fitPoints = []; + var stopNumbers = []; + var routeGeometryById = geometryByRouteId(routeGeometry); + + routes.forEach(function (route) { + var start = locations[route.start_location_idx]; + if (!start) return; + fitPoints.push([utils.locationLat(start), utils.locationLng(start)]); + options.routeMap.addVehicleMarker({ + lat: utils.locationLat(start), + lng: utils.locationLng(start), + color: route.color || '#2563eb', + }); + }); + + visits.forEach(function (visit, idx) { + var location = locations[visit.location_idx]; + if (!location) return; + fitPoints.push([utils.locationLat(location), utils.locationLng(location)]); + options.routeMap.addVisitMarker({ + lat: utils.locationLat(location), + lng: utils.locationLng(location), + color: assigned[idx] ? assigned[idx].color : '#64748b', + icon: utils.iconForVisit(visit), + assigned: !!assigned[idx], + }); + }); + + routes.forEach(function (route, routeIdx) { + var routeId = routeKey(route, routeIdx); + var style = routeStyle(route, routeId, focusedRouteId); + drawRouteGeometry(routeGeometryById[routeId], route.color, style); + (route.visits || []).forEach(function (visitIdx, sequenceIdx) { + var visit = visits[visitIdx]; + if (!visit) return; + if (!focusedRouteId || focusedRouteId === routeId) { + stopNumbers.push({ + location: locations[visit.location_idx], + number: sequenceIdx + 1, + color: route.color, + }); + } + }); + }); + + placeStopNumbers(stopNumbers); + fitMapToPoints(fitPoints); + } + + function fitMapToPoints(points) { + if (!points.length || !options.routeMap) return; + if (options.routeMap.map && options.routeMap.map.invalidateSize) { + options.routeMap.map.invalidateSize(); + } + if (window.L && options.routeMap.map && options.routeMap.map.fitBounds) { + options.routeMap.map.fitBounds(window.L.latLngBounds(points), { + maxZoom: 12, + padding: [70, 70], + }); + return; + } + options.routeMap.fitBounds(); + } + + function placeStopNumbers(stops) { + var groups = {}; + stops.forEach(function (stop) { + if (!stop.location) return; + var key = stopLocationKey(stop.location); + if (!groups[key]) groups[key] = []; + groups[key].push(stop); + }); + + Object.keys(groups).forEach(function (key) { + var group = groups[key]; + group.forEach(function (stop, idx) { + addStopNumber(stop.location, stop.number, stop.color, stopOffset(idx, group.length)); + }); + }); + } + + function stopLocationKey(location) { + return [ + utils.locationLat(location).toFixed(6), + utils.locationLng(location).toFixed(6), + ].join(','); + } + + function stopOffset(index, count) { + if (count <= 1) return { x: 0, y: 0 }; + var angle = (-Math.PI / 2) + ((Math.PI * 2 * index) / count); + var radius = count === 2 ? 14 : 18; + return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }; + } + + function addStopNumber(location, number, color, offset) { + if (!location) return; + var marker = options.routeMap.addStopNumber({ + lat: utils.locationLat(location), + lng: utils.locationLng(location), + number: number, + color: color || '#2563eb', + }); + applyStopOffset(marker, offset || { x: 0, y: 0 }); + } + + function applyStopOffset(marker, offset) { + if (!marker || (!offset.x && !offset.y)) return; + var element = marker.getElement ? marker.getElement() : marker._icon; + var stop = element && element.querySelector ? element.querySelector('.sf-marker-stop') : null; + if (stop) stop.style.transform = 'translate(' + offset.x.toFixed(1) + 'px, ' + offset.y.toFixed(1) + 'px)'; + } + + function geometryByRouteId(routeGeometry) { + return ((routeGeometry && routeGeometry.routes) || []).reduce(function (index, route) { + index[String(route.routeId)] = route; + return index; + }, {}); + } + + function drawRouteGeometry(routeGeometry, color, style) { + if (!routeGeometry || !routeGeometry.segments) return; + routeGeometry.segments.forEach(function (segment) { + if (segment.geometryStatus !== 'ROUTED' || !segment.reachable || !segment.encodedPolyline) return; + options.routeMap.drawEncodedRoute({ + encoded: segment.encodedPolyline, + color: color || '#2563eb', + opacity: style && style.opacity, + weight: style && style.weight, + }); + }); + } + + function routeKey(route, idx) { + return String(route.id || route.technician_name || ('route-' + idx)); + } + + function routeStyle(route, routeId, focusedRouteId) { + if (!focusedRouteId) return { color: route.color || '#2563eb', opacity: 0.82, weight: 3 }; + return focusedRouteId === routeId + ? { color: route.color || '#2563eb', opacity: 1, weight: 5 } + : { color: route.color || '#2563eb', opacity: 0.18, weight: 2 }; + } + }; +})(); diff --git a/static/app-render-routes.js b/static/app-render-routes.js new file mode 100644 index 0000000000000000000000000000000000000000..6e96f09a96895dd9503753f7c93ea19d29260328 --- /dev/null +++ b/static/app-render-routes.js @@ -0,0 +1,106 @@ +/* app-render-routes.js - route list rendering for the Bergamo FSR demo */ + +(function () { + 'use strict'; + + var FSR = window.FSR = window.FSR || {}; + var utils = FSR.utils; + + FSR.createRouteListRenderer = function (options) { + var SF = options.SF; + return { renderRouteCards: renderRouteCards }; + + function renderRouteCards(plan, routeGeometry) { + var routeGeometryById = geometryByRouteId(routeGeometry); + options.routeCards.innerHTML = ''; + (plan.technician_routes || []).forEach(function (route, routeIdx) { + var stats = utils.routeStats(plan, route); + var routeId = routeKey(route, routeIdx); + var focusedRouteId = options.getFocusedRouteId ? options.getFocusedRouteId() : null; + var isFocused = focusedRouteId === routeId; + var card = SF.el('div', { + className: 'fsr-route-row' + (isFocused ? ' is-focused' : ''), + role: 'button', + tabIndex: 0, + dataset: { routeId: routeId }, + }); + card.addEventListener('click', function () { focusRoute(routeId); }); + card.addEventListener('keydown', function (event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + focusRoute(routeId); + } + }); + + var top = SF.el('div', { className: 'fsr-route-row__top' }); + top.appendChild(SF.el('strong', null, route.technician_name || route.id || ('Technician ' + (routeIdx + 1)))); + top.appendChild(SF.el('span', { className: 'fsr-route-tag' }, String((route.visits || []).length) + ' stops')); + card.appendChild(top); + + var meta = SF.el('div', { className: 'fsr-route-row__meta' }); + meta.appendChild(SF.el('span', null, utils.formatDuration(stats.travelMinutes) + ' travel')); + meta.appendChild(SF.el('span', null, utils.formatDuration(stats.serviceMinutes) + ' service')); + meta.appendChild(SF.el('span', null, route.territory || 'no territory')); + if (stats.lateMinutes) meta.appendChild(SF.el('span', null, utils.formatDuration(stats.lateMinutes) + ' late')); + if (stats.overtimeMinutes) meta.appendChild(SF.el('span', null, utils.formatDuration(stats.overtimeMinutes) + ' overtime')); + if (stats.unreachable || stats.missingSkills || stats.missingParts) { + meta.appendChild(SF.el('span', null, String(stats.unreachable + stats.missingSkills + stats.missingParts) + ' hard issues')); + } + if (hasGeometryGaps(routeGeometryById[routeId])) meta.appendChild(SF.el('span', null, 'Geometry gaps')); + card.appendChild(meta); + + var action = SF.createButton({ + text: isFocused ? 'Show All' : 'Highlight', + variant: isFocused ? 'default' : 'ghost', + }); + action.addEventListener('click', function (event) { + event.stopPropagation(); + focusRoute(routeId); + }); + card.appendChild(SF.el('div', { className: 'fsr-route-row__actions' }, action)); + options.routeCards.appendChild(card); + }); + renderUnassignedCard(plan); + } + + function renderUnassignedCard(plan) { + var assigned = utils.assignedVisitSet(plan.technician_routes || []); + var rows = (plan.service_visits || []).reduce(function (items, visit, idx) { + if (assigned[idx]) return items; + items.push([ + visit.customer || visit.name || visit.id, + utils.timeLabel(visit.earliest_minute) + '-' + utils.timeLabel(visit.latest_minute), + utils.formatDuration(visit.duration_minutes || 0), + ]); + return items; + }, []); + if (!rows.length) return; + + var card = SF.el('div', { className: 'fsr-route-empty' }); + card.appendChild(SF.el('strong', null, 'Unassigned visits')); + card.appendChild(SF.createTable({ columns: ['Visit', 'Window', 'Duration'], rows: rows })); + options.routeCards.appendChild(card); + } + + function geometryByRouteId(routeGeometry) { + return ((routeGeometry && routeGeometry.routes) || []).reduce(function (index, route) { + index[String(route.routeId)] = route; + return index; + }, {}); + } + + function hasGeometryGaps(routeGeometry) { + return !!routeGeometry && (routeGeometry.segments || []).some(function (segment) { + return segment.geometryStatus !== 'ROUTED'; + }); + } + + function focusRoute(routeId) { + if (options.onFocusRoute) options.onFocusRoute(routeId); + } + + function routeKey(route, idx) { + return String(route.id || route.technician_name || ('route-' + idx)); + } + }; +})(); diff --git a/static/app-render.js b/static/app-render.js new file mode 100644 index 0000000000000000000000000000000000000000..1aaa1b85b42960841bca24a6ecca6496c41f2984 --- /dev/null +++ b/static/app-render.js @@ -0,0 +1,190 @@ +/* app-render.js - top-level rendering coordinator for the Bergamo FSR demo */ + +(function () { + 'use strict'; + + var FSR = window.FSR = window.FSR || {}; + var utils = FSR.utils; + + FSR.createRenderer = function (options) { + var SF = options.SF; + var routeTimeline = null; + var mapRenderer = FSR.createMapRenderer(options); + var routeListRenderer = FSR.createRouteListRenderer(options); + + return { + buildAnalysisBody: buildAnalysisBody, + destroy: destroy, + renderAll: renderAll, + renderApiGuide: renderApiGuide, + }; + + function renderAll(plan, routeGeometry) { + renderSummary(plan); + mapRenderer.renderMap(plan, routeGeometry); + routeListRenderer.renderRouteCards(plan, routeGeometry); + renderTimeline(plan); + renderTables(plan); + } + + function renderSummary(plan) { + var routes = plan.technician_routes || []; + var visits = plan.service_visits || []; + var assigned = utils.assignedVisitSet(routes); + var assignedCount = Object.keys(assigned).length; + var routeMetrics = routes.map(function (route) { return utils.routeStats(plan, route); }); + var travelMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.travelMinutes; }, 0); + var serviceMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.serviceMinutes; }, 0); + var routeIssues = routeMetrics.reduce(function (sum, stats) { + return sum + stats.unreachable + stats.missingSkills + stats.missingParts + stats.lateMinutes + stats.overtimeMinutes; + }, 0); + + options.summaryContainer.innerHTML = ''; + options.summaryContainer.appendChild(SF.createTable({ + columns: ['Dataset', 'Visits', 'Assigned', 'Technicians', 'Travel', 'Service', 'Hard issues', 'Score'], + rows: [[ + options.getSelectedDemoId() || options.getDemoCatalog().defaultId || 'STANDARD', + String(visits.length), + String(assignedCount), + String(routes.length), + utils.formatDuration(travelMinutes), + utils.formatDuration(serviceMinutes), + String(routeIssues + Math.max(0, visits.length - assignedCount)), + String(plan.score || 'unsolved'), + ]], + })); + } + + function renderTimeline(plan) { + var timelineConfig = buildTimelineConfig(plan); + options.timelineContainer.innerHTML = ''; + options.timelineContainer.appendChild(SF.el('div', { className: 'sf-section' }, SF.createTable({ + columns: ['Route lanes', 'Window', 'Source'], + rows: [[String((plan.technician_routes || []).length), '08:00-18:00', 'Latest SolverForge solution payload']], + }))); + + if (!routeTimeline) routeTimeline = SF.rail.createTimeline(timelineConfig); + else routeTimeline.setModel(timelineConfig.model); + options.timelineContainer.appendChild(routeTimeline.el); + } + + function buildTimelineConfig(plan) { + return { + label: 'Technician', + labelWidth: 280, + title: 'Bergamo Field Service Routes', + subtitle: 'Ordered service visits per technician', + model: { + axis: buildDayAxis(), + lanes: (plan.technician_routes || []).map(routeLane(plan)), + }, + }; + } + + function routeLane(plan) { + return function (route, routeIdx) { + var items = utils.routeSchedule(plan, route).map(function (entry, entryIdx) { + return { + id: 'route-' + routeIdx + '-visit-' + entryIdx, + startMinute: entry.start, + endMinute: entry.end, + label: entry.visit.customer || entry.visit.name || entry.visit.id, + meta: utils.timeLabel(entry.start) + '-' + utils.timeLabel(entry.end), + tone: utils.toneForRoute(routeIdx), + }; + }); + var stats = utils.routeStats(plan, route); + return { + id: route.id || ('route-' + routeIdx), + label: route.technician_name || route.id || ('Technician ' + (routeIdx + 1)), + mode: 'detailed', + badges: utils.routeBadges(stats), + stats: [ + { label: 'Stops', value: (route.visits || []).length }, + { label: 'Travel', value: utils.formatDuration(stats.travelMinutes) }, + { label: 'Service', value: utils.formatDuration(stats.serviceMinutes) }, + ], + items: items, + }; + }; + } + + function buildDayAxis() { + var ticks = []; + for (var minute = options.dayStart; minute <= options.dayEnd; minute += 60) { + ticks.push({ id: 'tick-' + minute, minute: minute, label: utils.timeLabel(minute) }); + } + return { + startMinute: options.dayStart, + endMinute: options.dayEnd, + days: [{ id: 'bergamo-day', label: 'Service day', subLabel: '08:00-18:00', startMinute: options.dayStart, endMinute: options.dayEnd }], + ticks: ticks, + initialViewport: { startMinute: options.dayStart, endMinute: options.dayEnd }, + }; + } + + function renderTables(plan) { + options.tablesContainer.innerHTML = ''; + ['technician_routes', 'service_visits', 'locations'].forEach(function (key) { + var rows = plan[key] || []; + if (!rows.length) return; + var columns = Object.keys(rows[0]).filter(function (column) { return column !== 'score'; }); + var values = rows.map(function (row) { + return columns.map(function (column) { + var value = row[column]; + if (value == null) return '-'; + if (Array.isArray(value)) return value.join(', '); + return String(value); + }); + }); + var section = SF.el('div', { className: 'sf-section' }); + section.appendChild(SF.el('h3', null, utils.title(key))); + section.appendChild(SF.createTable({ columns: columns, rows: values })); + options.tablesContainer.appendChild(section); + }); + } + + function renderApiGuide() { + var catalog = options.getDemoCatalog(); + var demoId = options.getSelectedDemoId() || catalog.defaultId; + options.apiGuideContainer.innerHTML = ''; + options.apiGuideContainer.appendChild(SF.createApiGuide({ + endpoints: [ + { method: 'GET', path: '/demo-data', description: 'Discover demo datasets', curl: utils.buildCurl('GET', '/demo-data') }, + { method: 'GET', path: '/demo-data/' + (demoId || '{id}'), description: 'Fetch Bergamo seed data', curl: utils.buildCurl('GET', '/demo-data/' + (demoId || 'STANDARD')) }, + { method: 'POST', path: '/jobs', description: 'Create a retained solve job', curl: utils.buildCurl('POST', '/jobs', true) }, + { method: 'GET', path: '/jobs/{id}/events', description: 'Stream typed SolverForge lifecycle events', curl: utils.buildCurl('GET', '/jobs/{id}/events') }, + { method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch latest route snapshot', curl: utils.buildCurl('GET', '/jobs/{id}/snapshot') }, + { method: 'GET', path: '/jobs/{id}/routes?snapshot_revision={n}', description: 'Fetch encoded route geometry for a retained snapshot', curl: utils.buildCurl('GET', '/jobs/{id}/routes?snapshot_revision={n}') }, + { method: 'GET', path: '/jobs/{id}/analysis', description: 'Analyze the latest retained score', curl: utils.buildCurl('GET', '/jobs/{id}/analysis') }, + ], + })); + } + + function buildAnalysisBody(analysis) { + var container = SF.el('div', { className: 'fsr-analysis-body' }); + if (!analysis || !analysis.constraints) { + container.appendChild(SF.el('p', null, 'No analysis available.')); + return container; + } + + container.appendChild(SF.el('p', null, SF.el('strong', null, 'Score: '), String(analysis.score))); + container.appendChild(SF.createTable({ + columns: ['Constraint', 'Weight', 'Score', 'Matches'], + rows: analysis.constraints.map(function (constraint) { + return [ + constraint.name, + constraint.weight, + constraint.score, + String(constraint.matchCount || 0), + ]; + }), + })); + return container; + } + + function destroy() { + if (routeTimeline) routeTimeline.destroy(); + } + }; +})(); diff --git a/static/app-route-state.js b/static/app-route-state.js new file mode 100644 index 0000000000000000000000000000000000000000..c29070a74ae18be549ac50f31fbc1e39397c7604 --- /dev/null +++ b/static/app-route-state.js @@ -0,0 +1,101 @@ +/* app-route-state.js - snapshot-scoped route geometry loading */ + +(function () { + 'use strict'; + + var FSR = window.FSR = window.FSR || {}; + + FSR.createRouteGeometryController = function (options) { + var currentPlanIdentity = null; + var currentRouteIdentity = null; + var routeInFlightIdentity = null; + var latestRouteIdentity = null; + var routeRequestToken = 0; + + return { + identityFrom: identityFrom, + invalidate: invalidate, + load: load, + setPlanIdentity: setPlanIdentity, + }; + + function invalidate() { + routeRequestToken += 1; + currentPlanIdentity = null; + currentRouteIdentity = null; + routeInFlightIdentity = null; + latestRouteIdentity = null; + if (options.onClearFocus) options.onClearFocus(); + options.onRoutesChange(null); + } + + function setPlanIdentity(identity) { + currentPlanIdentity = identity; + if (!identityEquals(currentRouteIdentity, identity)) { + currentRouteIdentity = null; + options.onRoutesChange(null); + } + } + + function load(identity) { + if (!identity) return Promise.resolve(); + latestRouteIdentity = identity; + if (routeInFlightIdentity || identityEquals(currentRouteIdentity, identity)) { + return Promise.resolve(); + } + return fetchLatest(); + } + + function fetchLatest() { + var identity = latestRouteIdentity; + if (!identity || routeInFlightIdentity || identityEquals(currentRouteIdentity, identity)) { + return Promise.resolve(); + } + var token = routeRequestToken; + routeInFlightIdentity = identity; + return options.utils.fetchJobRoutes(identity.jobId, identity.snapshotRevision) + .then(function (routes) { + if (!responseStillCurrent(token, identity)) return; + currentRouteIdentity = identity; + options.onRoutesChange(routes); + }) + .catch(function (err) { + if (!responseStillCurrent(token, identity)) return; + currentRouteIdentity = null; + options.onRoutesChange(null); + console.error('Route geometry failed:', err); + }) + .then(function () { + if (identityEquals(routeInFlightIdentity, identity)) routeInFlightIdentity = null; + if ( + latestRouteIdentity + && !identityEquals(latestRouteIdentity, identity) + && responseStillCurrent(token, latestRouteIdentity) + && !identityEquals(currentRouteIdentity, latestRouteIdentity) + ) { + return fetchLatest(); + } + }); + } + + function identityFrom(meta) { + var jobId = meta && meta.jobId != null ? meta.jobId : options.solver.getJobId(); + var snapshotRevision = meta && meta.snapshotRevision != null + ? meta.snapshotRevision + : options.solver.getSnapshotRevision(); + if (jobId == null || jobId === '' || snapshotRevision == null) return null; + return { jobId: String(jobId), snapshotRevision: String(snapshotRevision) }; + } + + function responseStillCurrent(token, identity) { + return token === routeRequestToken && identityEquals(currentPlanIdentity, identity); + } + + function identityEquals(left, right) { + return !!left + && !!right + && left.jobId === right.jobId + && left.snapshotRevision === right.snapshotRevision; + } + }; +})(); diff --git a/static/app-utils.js b/static/app-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..a6bcff6b1413667532f501b2205db5a75842c6b5 --- /dev/null +++ b/static/app-utils.js @@ -0,0 +1,233 @@ +/* app-utils.js โ€” shared helpers for the Bergamo FSR demo */ + +(function () { + 'use strict'; + + var FSR = window.FSR = window.FSR || {}; + + FSR.utils = { + assignedVisitSet: assignedVisitSet, + buildCurl: buildCurl, + clonePlan: clonePlan, + fetchDemoCatalog: fetchDemoCatalog, + fetchDemoPlan: fetchDemoPlan, + fetchJobRoutes: fetchJobRoutes, + findHeaderButton: findHeaderButton, + formatDuration: formatDuration, + iconForVisit: iconForVisit, + legFor: legFor, + locationLat: locationLat, + locationLng: locationLng, + maskContains: maskContains, + routeBadges: routeBadges, + routeSchedule: routeSchedule, + routeStats: routeStats, + timeLabel: timeLabel, + title: title, + toneForRoute: toneForRoute, + }; + + function buildCurl(method, path, json) { + var parts = ['curl']; + if (method && method !== 'GET') parts.push('-X', method); + if (json) parts.push('-H', '"Content-Type: application/json"', '-d', '@plan.json'); + parts.push(window.location.origin + path); + return parts.join(' '); + } + + function fetchDemoCatalog() { + return requestJson('/demo-data', 'demo data catalog').then(function (catalog) { + if (!catalog || typeof catalog.defaultId !== 'string' || !Array.isArray(catalog.availableIds)) { + throw new Error('demo data catalog is missing defaultId or availableIds'); + } + return { defaultId: catalog.defaultId, availableIds: catalog.availableIds.slice() }; + }); + } + + function fetchDemoPlan(demoId) { + return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"'); + } + + function fetchJobRoutes(jobId, snapshotRevision) { + return requestJson( + '/jobs/' + encodeURIComponent(jobId) + '/routes?snapshot_revision=' + encodeURIComponent(snapshotRevision), + 'route geometry for job "' + jobId + '"' + ); + } + + function requestJson(path, label) { + return fetch(path).then(function (response) { + if (!response.ok) throw new Error(label + ' returned HTTP ' + response.status); + return response.json(); + }); + } + + function routeSchedule(plan, route) { + var visits = plan.service_visits || []; + var entries = []; + var clock = route.shift_start_minute; + var previous = route.start_location_idx; + + (route.visits || []).forEach(function (visitIdx) { + var visit = visits[visitIdx]; + if (!visit) return; + var leg = legFor(plan, previous, visit.location_idx); + clock += leg && leg.reachable ? Math.ceil((leg.duration_seconds || 0) / 60) : 0; + if (clock < visit.earliest_minute) clock = visit.earliest_minute; + var start = clock; + var end = start + Math.max(0, visit.duration_minutes || 0); + entries.push({ visit: visit, start: start, end: end }); + clock = end; + previous = visit.location_idx; + }); + + return entries; + } + + function routeStats(plan, route) { + var visits = plan.service_visits || []; + var travelMinutes = 0; + var serviceMinutes = 0; + var lateMinutes = 0; + var overtimeMinutes = 0; + var missingSkills = 0; + var missingParts = 0; + var unreachable = 0; + var clock = route.shift_start_minute; + var previous = route.start_location_idx; + + (route.visits || []).forEach(function (visitIdx) { + var visit = visits[visitIdx]; + if (!visit) { + unreachable += 1; + return; + } + var leg = legFor(plan, previous, visit.location_idx); + if (!leg || !leg.reachable) { + unreachable += 1; + } else { + var legMinutes = Math.ceil((leg.duration_seconds || 0) / 60); + travelMinutes += legMinutes; + clock += legMinutes; + } + if (clock < visit.earliest_minute) clock = visit.earliest_minute; + if (clock > visit.latest_minute) lateMinutes += clock - visit.latest_minute; + if (!maskContains(route.skill_mask, visit.required_skill_mask)) missingSkills += 1; + if (!maskContains(route.inventory_mask, visit.required_parts_mask)) missingParts += 1; + serviceMinutes += Math.max(0, visit.duration_minutes || 0); + clock += Math.max(0, visit.duration_minutes || 0); + previous = visit.location_idx; + }); + + var returnLeg = legFor(plan, previous, route.end_location_idx); + if (!returnLeg || !returnLeg.reachable) { + unreachable += 1; + } else { + var returnMinutes = Math.ceil((returnLeg.duration_seconds || 0) / 60); + travelMinutes += returnMinutes; + clock += returnMinutes; + } + + var routeMinutes = Math.max(0, clock - route.shift_start_minute); + overtimeMinutes += Math.max(0, clock - route.shift_end_minute); + overtimeMinutes += Math.max(0, routeMinutes - route.max_route_minutes); + + return { + travelMinutes: travelMinutes, + serviceMinutes: serviceMinutes, + lateMinutes: lateMinutes, + overtimeMinutes: overtimeMinutes, + missingSkills: missingSkills, + missingParts: missingParts, + unreachable: unreachable, + }; + } + + function legFor(plan, from, to) { + var width = (plan.locations || []).length; + var direct = (plan.travel_legs || [])[from * width + to]; + if (direct && direct.from_location_idx === from && direct.to_location_idx === to) { + return direct; + } + return (plan.travel_legs || []).find(function (leg) { + return leg.from_location_idx === from && leg.to_location_idx === to; + }); + } + + function assignedVisitSet(routes) { + var assigned = {}; + (routes || []).forEach(function (route) { + (route.visits || []).forEach(function (visitIdx) { + assigned[visitIdx] = route; + }); + }); + return assigned; + } + + function locationLat(location) { + if (location.lat != null) return Number(location.lat); + return Number(location.lat_e6 || 0) / 1000000; + } + + function locationLng(location) { + if (location.lng != null) return Number(location.lng); + return Number(location.lng_e6 || 0) / 1000000; + } + + function routeBadges(stats) { + var badges = []; + if (stats.unreachable) badges.push('Routing'); + if (stats.missingSkills) badges.push('Skills'); + if (stats.missingParts) badges.push('Parts'); + if (stats.lateMinutes) badges.push('Late'); + if (stats.overtimeMinutes) badges.push('Overtime'); + return badges.length ? badges : ['Feasible']; + } + + function iconForVisit(visit) { + if ((visit.required_skill_mask & 8) === 8) return 'fa-elevator'; + if ((visit.required_skill_mask & 4) === 4) return 'fa-faucet'; + if ((visit.required_skill_mask & 2) === 2) return 'fa-bolt'; + return 'fa-screwdriver-wrench'; + } + + function maskContains(available, required) { + return ((available || 0) & (required || 0)) === (required || 0); + } + + function toneForRoute(index) { + return ['blue', 'emerald', 'amber', 'rose', 'violet', 'slate'][index % 6]; + } + + function formatDuration(minutes) { + var value = Math.max(0, Math.round(minutes || 0)); + var h = Math.floor(value / 60); + var m = value % 60; + if (!h) return String(m) + 'm'; + return String(h) + 'h ' + String(m).padStart(2, '0') + 'm'; + } + + function timeLabel(minute) { + var h = Math.floor(minute / 60); + var m = minute % 60; + return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'); + } + + function findHeaderButton(header, label) { + var buttons = header.querySelectorAll('button'); + for (var i = 0; i < buttons.length; i += 1) { + if ((buttons[i].textContent || '').trim() === label) return buttons[i]; + } + return null; + } + + function clonePlan(data) { + return JSON.parse(JSON.stringify(data)); + } + + function title(text) { + return String(text || '') + .replace(/_/g, ' ') + .replace(/\b\w/g, function (match) { return match.toUpperCase(); }); + } +})(); diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000000000000000000000000000000000000..85098f9c5317a4ce954ecfd6b0d525ae7c056589 --- /dev/null +++ b/static/app.css @@ -0,0 +1,263 @@ +/* Bergamo FSR app shell and map layout */ + +:root { + color-scheme: light; + --fsr-bg: #f6f8fb; + --fsr-surface: #ffffff; + --fsr-surface-muted: #f8fafc; + --fsr-text: #0f172a; + --fsr-text-muted: #475569; + --fsr-border: #d8e0ea; + --fsr-map-rail: #e7edf4; +} + +html, +body { + min-height: 100%; + margin: 0; + background: var(--fsr-bg); + color: var(--fsr-text); +} + +body { + color-scheme: light; +} + +#sf-app.fsr-app { + min-height: 100vh; + max-width: 100vw; + overflow-x: hidden; + background: var(--fsr-bg); + color: var(--fsr-text); + color-scheme: light; +} + +.fsr-app, +.fsr-app * { + box-sizing: border-box; +} + +.fsr-app .sf-content { + width: 100%; + padding: 14px 24px; +} + +.fsr-app .sf-section { + background: var(--fsr-surface); + border: 1px solid var(--fsr-border); + color: var(--fsr-text); +} + +.fsr-app .sf-statusbar, +.fsr-app .sf-footer { + background: var(--fsr-surface-muted); +} + +.fsr-app .sf-table-container, +.fsr-app .sf-table, +.fsr-app .sf-modal, +.fsr-app select, +.fsr-app input, +.fsr-app textarea, +.fsr-app button { + color-scheme: light; +} + +.fsr-app .sf-table-container, +.fsr-app .sf-table { + background: var(--fsr-surface); +} + +.fsr-app .sf-table th { + background: #f1f5f9; + color: #334155; +} + +.fsr-app .sf-table td { + background: var(--fsr-surface); + color: var(--fsr-text); +} + +.fsr-summary { + padding-bottom: 10px; +} + +.fsr-map-shell { + display: grid; + grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); + gap: 16px; + align-items: stretch; + min-height: clamp(520px, 67vh, 760px); +} + +.fsr-map { + flex: 1; + min-height: clamp(520px, 67vh, 760px); + overflow: hidden; + border-radius: 8px; + background: #dbe7d3; +} + +.fsr-map-card, +.fsr-routes-card { + display: flex; + flex-direction: column; + min-height: 100%; + padding: 16px; + border-radius: 8px; +} + +.fsr-map-card h3, +.fsr-routes-card h3 { + margin: 0 0 10px; +} + +.fsr-route-list { + display: grid; + gap: 10px; + align-content: start; + max-height: clamp(520px, 67vh, 760px); + overflow: auto; +} + +.fsr-route-row, +.fsr-route-empty { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 8px; + background: #ffffff; +} + +.fsr-route-row { + cursor: pointer; +} + +.fsr-route-row.is-focused { + border-color: rgba(16, 185, 129, 0.58); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.13); +} + +.fsr-route-row:focus-visible { + outline: 3px solid rgba(16, 185, 129, 0.35); + outline-offset: 2px; +} + +.fsr-route-row__top, +.fsr-route-row__meta, +.fsr-route-row__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.fsr-route-row__top { + justify-content: space-between; +} + +.fsr-route-row__meta { + color: var(--fsr-text-muted); + font-size: 13px; + line-height: 1.35; +} + +.fsr-route-tag { + border-radius: 999px; + background: #ecfdf5; + color: #047857; + font-size: 12px; + font-weight: 700; + padding: 3px 8px; + white-space: nowrap; +} + +.fsr-route-row .sf-btn--ghost { + background: #f8fafc; + border: 1px solid rgba(15, 23, 42, 0.1); + color: var(--fsr-text); +} + +.fsr-route-row .sf-btn--ghost:hover { + background: #eef2f7; +} + +.fsr-route-empty { + color: var(--fsr-text); +} + +.fsr-map .leaflet-container, +.fsr-map .leaflet-pane, +.fsr-map .leaflet-tile-pane { + background: #dbe7d3; +} + +@media (max-width: 980px) { + .fsr-app .sf-content { + padding-inline: 14px; + } + + .fsr-map-shell { + grid-template-columns: 1fr; + } + + .fsr-route-list { + max-height: none; + } +} + +@media (max-width: 760px) { + .fsr-app .sf-header { + height: auto; + min-height: var(--sf-header-height); + flex-wrap: wrap; + gap: 8px 12px; + padding: 10px 14px; + } + + .fsr-app .sf-header-brand { + flex: 1 1 calc(100% - 70px); + min-width: 0; + } + + .fsr-app .sf-header-title, + .fsr-app .sf-header-subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .fsr-app .sf-header-nav { + order: 3; + flex: 1 1 100%; + margin-left: 0; + overflow-x: auto; + padding-bottom: 2px; + } + + .fsr-app .sf-header-actions { + order: 4; + margin-left: 0; + overflow-x: auto; + padding-bottom: 2px; + } + + .fsr-app .sf-statusbar { + overflow-x: auto; + padding-inline: 14px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: light; + } + + html, + body, + #sf-app.fsr-app { + background: var(--fsr-bg); + color: var(--fsr-text); + } +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000000000000000000000000000000000000..9efec52b4ed59a3e6aa5851abf6fc327de537a8c --- /dev/null +++ b/static/app.js @@ -0,0 +1,261 @@ +/* app.js - lifecycle bootstrap for the Bergamo field service routing demo */ + +(async function () { + 'use strict'; + + var DAY_START = 8 * 60; + var DAY_END = 18 * 60; + var DEFAULT_CENTER = [45.698, 9.677]; + var utils = window.FSR.utils; + + var config = await fetch('/sf-config.json').then(function (response) { return response.json(); }); + var uiModel = await fetch('/generated/ui-model.json').then(function (response) { return response.json(); }); + var app = document.getElementById('sf-app'); + app.className = 'sf-app fsr-app'; + + var backend = SF.createBackend({ baseUrl: '' }); + var statusBar = SF.createStatusBar({ constraints: uiModel.constraints || [] }); + var currentPlan = null; + var currentRoutes = null; + var demoData = null; + var bootstrapError = null; + var lastAnalysis = null; + var routeMap = null; + var renderer = null; + var routeGeometry = null; + var focusedRouteId = null; + + var layout = window.FSR.createAppLayout({ + SF: SF, + app: app, + config: config, + statusBar: statusBar, + onSolve: function () { loadAndSolve(); }, + onPause: function () { pauseSolve(); }, + onResume: function () { resumeSolve(); }, + onCancel: function () { cancelSolve(); }, + onAnalyze: function () { openAnalysis(); }, + onMapTabShown: function () { + window.setTimeout(function () { + if (routeMap && routeMap.map && routeMap.map.invalidateSize) routeMap.map.invalidateSize(); + renderCurrentPlan(); + }, 80); + }, + }); + + demoData = window.FSR.createDemoDataController({ + SF: SF, + getCurrentPlan: function () { return currentPlan; }, + onCatalog: function () { if (renderer) renderer.renderApiGuide(); }, + onError: reportBootstrapError, + onLoadingChange: updateSolveActionAvailability, + onPlan: handleDemoPlanLoaded, + utils: utils, + }); + app.insertBefore(demoData.el, layout.bootstrapNotice.nextSibling); + + routeMap = SF.map.create({ container: 'fsr-map', center: DEFAULT_CENTER, zoom: 13 }); + renderer = window.FSR.createRenderer({ + SF: SF, + apiGuideContainer: layout.apiGuideContainer, + dayEnd: DAY_END, + dayStart: DAY_START, + getFocusedRouteId: function () { return focusedRouteId; }, + getDemoCatalog: function () { return demoData.getCatalog(); }, + getSelectedDemoId: function () { return demoData.getSelectedId(); }, + onFocusRoute: focusRoute, + routeCards: layout.routeCards, + routeMap: routeMap, + summaryContainer: layout.summaryContainer, + tablesContainer: layout.tablesContainer, + timelineContainer: layout.timelineContainer, + }); + + var analysisModal = SF.createModal({ title: 'Score Analysis', width: '760px' }); + var solver = SF.createSolver({ + backend: backend, + statusBar: statusBar, + onProgress: function (meta) { syncLifecycleMarkers(meta); }, + onPauseRequested: function (meta) { syncLifecycleMarkers(meta); }, + onSolution: function (snapshot, meta) { + renderSnapshot(snapshot, meta); + syncLifecycleMarkers(meta); + }, + onPaused: function (snapshot, meta) { + renderSnapshot(snapshot, meta); + syncLifecycleMarkers(meta); + }, + onResumed: function (meta) { syncLifecycleMarkers(meta); }, + onCancelled: function (snapshot, meta) { + renderSnapshot(snapshot, meta); + syncLifecycleMarkers(meta); + }, + onComplete: function (snapshot, meta) { + renderSnapshot(snapshot, meta); + syncLifecycleMarkers(meta); + }, + onFailure: function (message, meta, snapshot, analysis) { + renderSnapshot(snapshot, meta); + if (analysis) lastAnalysis = analysis; + console.error('Solver job failed:', message); + syncLifecycleMarkers(meta); + }, + onAnalysis: function (analysis) { + lastAnalysis = analysis; + syncLifecycleMarkers(); + }, + onError: function (message) { + console.error('Solver lifecycle failed:', message); + syncLifecycleMarkers(); + }, + }); + + routeGeometry = window.FSR.createRouteGeometryController({ + solver: solver, + utils: utils, + onClearFocus: function () { focusedRouteId = null; }, + onRoutesChange: function (routes) { + currentRoutes = routes; + renderCurrentPlan(); + }, + }); + + renderer.renderApiGuide(); + updateSolveActionAvailability(); + demoData.bootstrap(); + window.addEventListener('beforeunload', function () { renderer.destroy(); }); + + function loadAndSolve() { + if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED' || !canSolve()) return; + routeGeometry.invalidate(); + cleanupTerminalJob() + .then(function () { return demoData.resolvePlan(); }) + .then(function (data) { return solver.start(data); }) + .then(function () { syncLifecycleMarkers(); }) + .catch(function (err) { console.error('Solve start failed:', err); }); + } + + function pauseSolve() { + solver.pause().then(function () { syncLifecycleMarkers(); }).catch(function (err) { console.error('Pause failed:', err); }); + } + + function resumeSolve() { + solver.resume().then(function () { syncLifecycleMarkers(); }).catch(function (err) { console.error('Resume failed:', err); }); + } + + function cancelSolve() { + solver.cancel().then(function () { syncLifecycleMarkers(); }).catch(function (err) { console.error('Cancel failed:', err); }); + } + + function openAnalysis() { + var jobId = solver.getJobId(); + if (jobId == null || jobId === '') return; + solver.analyzeSnapshot() + .then(function (analysis) { + lastAnalysis = analysis; + analysisModal.setBody(renderer.buildAnalysisBody(analysis)); + analysisModal.open(); + }) + .catch(function (err) { console.error('Analysis failed:', err); }); + } + + function cleanupTerminalJob() { + var state = solver.getLifecycleState(); + var jobId = solver.getJobId(); + if (jobId == null || jobId === '' || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) { + return Promise.resolve(null); + } + return solver.delete().then(function () { + lastAnalysis = null; + syncLifecycleMarkers(); + }); + } + + function renderSnapshot(snapshot, meta) { + var identity = routeGeometry.identityFrom(meta); + if (snapshot && snapshot.solution) { + routeGeometry.setPlanIdentity(identity); + renderPlan(snapshot.solution); + } + routeGeometry.load(identity); + } + + function handleDemoPlanLoaded(plan) { + lastAnalysis = null; + routeGeometry.invalidate(); + clearBootstrapError(); + renderPlan(plan); + renderer.renderApiGuide(); + syncLifecycleMarkers(); + } + + function renderPlan(plan) { + currentPlan = utils.clonePlan(plan); + if (focusedRouteId && !hasRoute(currentPlan, focusedRouteId)) focusedRouteId = null; + renderCurrentPlan(); + } + + function renderCurrentPlan() { + if (renderer && currentPlan) renderer.renderAll(currentPlan, currentRoutes); + } + + function focusRoute(routeId) { + focusedRouteId = focusedRouteId === routeId ? null : routeId; + renderCurrentPlan(); + } + + function hasRoute(plan, routeId) { + return (plan.technician_routes || []).some(function (route, idx) { + return routeKey(route, idx) === routeId; + }); + } + + function routeKey(route, idx) { + return String(route.id || route.technician_name || ('route-' + idx)); + } + + function canSolve() { + return !bootstrapError && !!currentPlan && !demoData.isLoading(); + } + + function reportBootstrapError(err) { + bootstrapError = err && err.message ? err.message : String(err || 'unknown error'); + layout.bootstrapNotice.textContent = 'Demo data bootstrap failed: ' + bootstrapError; + layout.bootstrapNotice.style.display = ''; + app.dataset.bootstrapError = 'true'; + updateSolveActionAvailability(); + console.error('Demo data bootstrap failed:', err); + } + + function clearBootstrapError() { + bootstrapError = null; + layout.bootstrapNotice.textContent = ''; + layout.bootstrapNotice.style.display = 'none'; + delete app.dataset.bootstrapError; + } + + function updateSolveActionAvailability() { + var solveButton = utils.findHeaderButton(layout.header, 'Solve'); + if (!solveButton) return; + var disabled = !canSolve(); + solveButton.disabled = disabled; + solveButton.setAttribute('aria-disabled', disabled ? 'true' : 'false'); + solveButton.title = disabled + ? (bootstrapError ? 'Bergamo road data could not be loaded.' : 'Loading Bergamo demo data...') + : ''; + } + + function syncLifecycleMarkers(meta) { + var jobId = solver.getJobId(); + var snapshotRevision = solver.getSnapshotRevision(); + var lifecycleState = meta && meta.lifecycleState ? meta.lifecycleState : solver.getLifecycleState(); + + if (jobId) app.dataset.jobId = String(jobId); + else delete app.dataset.jobId; + if (snapshotRevision != null) app.dataset.snapshotRevision = String(snapshotRevision); + else delete app.dataset.snapshotRevision; + if (lifecycleState && lifecycleState !== 'IDLE') app.dataset.lifecycleState = lifecycleState; + else delete app.dataset.lifecycleState; + updateSolveActionAvailability(); + } +})(); diff --git a/static/generated/ui-model.json b/static/generated/ui-model.json new file mode 100644 index 0000000000000000000000000000000000000000..f3968348a385184f65781a275dc36553156e0acf --- /dev/null +++ b/static/generated/ui-model.json @@ -0,0 +1,50 @@ +{ + "constraints": [ + "assigned_visits", + "balance_workload", + "minimize_travel", + "priority_slack", + "reachable_legs", + "required_parts", + "required_skills", + "shift_capacity", + "territory_affinity", + "time_windows" + ], + "entities": [ + { + "label": "Technician Route", + "name": "technician_route", + "plural": "technician_routes" + } + ], + "facts": [ + { + "label": "Location", + "name": "location", + "plural": "locations" + }, + { + "label": "Service Visit", + "name": "service_visit", + "plural": "service_visits" + }, + { + "label": "Travel Leg", + "name": "travel_leg", + "plural": "travel_legs" + } + ], + "views": [ + { + "allowsUnassigned": false, + "entity": "technician_route", + "entityPlural": "technician_routes", + "id": "technician_route-visits", + "kind": "list", + "label": "Technician Route ยท visits", + "sourcePlural": "service_visits", + "variableField": "visits" + } + ] +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..1a0510edfaae4401f95526cd3bd5dfc0262bada0 --- /dev/null +++ b/static/index.html @@ -0,0 +1,29 @@ + + + + + + solverforge-fsr โ€” SolverForge + + + + + + + + + +
+ + + + + + + + + + + + + diff --git a/static/sf-config.json b/static/sf-config.json new file mode 100644 index 0000000000000000000000000000000000000000..34d834e36117769495a1c19933bfc51eebac9ce7 --- /dev/null +++ b/static/sf-config.json @@ -0,0 +1,40 @@ +{ + "title": "SolverForge FSR", + "subtitle": "Bergamo technician routes, road travel, time windows, skills, and parts", + "constraints": [ + "assigned_visits", + "reachable_legs", + "required_skills", + "required_parts", + "time_windows", + "shift_capacity", + "minimize_travel", + "balance_workload", + "territory_affinity", + "priority_slack" + ], + "entities": [ + { + "name": "technician_route", + "label": "TechnicianRoute", + "plural": "technician_routes" + } + ], + "facts": [ + { + "name": "location", + "label": "Location", + "plural": "locations" + }, + { + "name": "service_visit", + "label": "ServiceVisit", + "plural": "service_visits" + }, + { + "name": "travel_leg", + "label": "TravelLeg", + "plural": "travel_legs" + } + ] +} diff --git a/tests/e2e/app.spec.js b/tests/e2e/app.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..de0fb6d2da2e47ab6b8d606d754c6d49a1404198 --- /dev/null +++ b/tests/e2e/app.spec.js @@ -0,0 +1,92 @@ +const { test, expect } = require('playwright/test'); + +const transparentPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +test.beforeEach(async ({ page }) => { + await page.route('https://*.tile.openstreetmap.org/**', (route) => route.fulfill({ + status: 200, + contentType: 'image/png', + body: transparentPng, + })); +}); + +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 FSR 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/modules/sf-map.js')).resolves.toBeOK(); + await expect(request.get('/app.js')).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({ + defaultId: 'STANDARD', + availableIds: ['STANDARD'], + }); + + await page.goto('/'); + await expect(page).toHaveTitle('solverforge-fsr โ€” SolverForge'); + await expect(page.getByText('SolverForge FSR')).toBeVisible(); + await expect(page.getByText('Bergamo technician routes, road travel, time windows, skills, and parts')).toBeVisible(); + await expect(page.locator('#sfStatusText')).toHaveText('Ready'); + await expect(page.locator('.sf-constraint-dot')).toHaveCount(10); + + for (const tab of ['Map', 'Routes', 'Data', 'REST API']) { + await expect(page.getByRole('tab', { name: tab })).toBeVisible(); + } + + await expect(page.getByRole('cell', { name: 'STANDARD' })).toBeVisible(); + await expect(page.locator('.fsr-route-row')).toHaveCount(6); + await expect(page.locator('.sf-marker-vehicle')).toHaveCount(6); + await expect(page.locator('.sf-marker-visit')).toHaveCount(48); + await expect(page.getByText('Unassigned visits')).toBeVisible(); + + expect(errors).toEqual([]); +}); + +test('renders FSR-specific panels and visible REST API guide', async ({ page }) => { + const errors = collectBrowserErrors(page); + + await page.goto('/'); + await page.getByRole('tab', { name: 'Routes' }).click(); + await expect(page.getByText('Bergamo Field Service Routes')).toBeVisible(); + await expect(page.locator('.sf-rail-timeline')).toBeVisible(); + + await page.getByRole('tab', { name: 'Data' }).click(); + await expect(page.getByRole('heading', { name: 'Technician Routes' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Service Visits' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Locations' })).toBeVisible(); + + await page.getByRole('tab', { name: 'REST API' }).click(); + await expect(page.getByRole('heading', { name: 'GET /demo-data/STANDARD' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'GET /jobs/{id}/routes?snapshot_revision={n}' })).toBeVisible(); + + expect(errors).toEqual([]); +}); + +test('highlights a technician route without changing the route count', async ({ page }) => { + const errors = collectBrowserErrors(page); + + await page.goto('/'); + const firstRoute = page.locator('.fsr-route-row').first(); + await firstRoute.click(); + + await expect(firstRoute).toHaveClass(/is-focused/); + await expect(firstRoute.getByRole('button')).toHaveText('Show All'); + await expect(page.locator('.fsr-route-row')).toHaveCount(6); + + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 0000000000000000000000000000000000000000..73362820c2c5583101695b7a34bd8641e707a245 --- /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 || 17962); +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_fsr')}`, + cwd: rootDir, + url: `${baseURL}/health`, + timeout: 20_000, + reuseExistingServer: false, + }, +};