diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..8b7eda706bba05a219cdca7667528148f4acb1ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +target/ +.git/ +.osm_cache/ +test-results/ +playwright-report/ +*.rs.bk 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..2bde0126401ec7dbbae6377f8f1de8a2ca6da481 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +.osm_cache/ +**/*.rs.bk +test-results/ +playwright-report/ 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..13e425781ebc6703a94c8031cee4caa7dda77b8d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# Repository Guidelines + +## Project Structure And Naming + +This repo follows the current `solverforge-cli` app shape. The app package +version is `1.0.1`, and the release binary is `solverforge_deliveries`. + +- `src/domain/mod.rs` owns the `solverforge::planning_model!` manifest. +- `src/domain/plan.rs` owns the `Plan` planning solution. +- `src/domain/delivery.rs` owns the `Delivery` problem fact. +- `src/domain/vehicle.rs` owns the `Vehicle` planning entity and its + `delivery_order` list variable. +- `src/domain/preview.rs` owns transport/view preview structs. +- `src/domain/route_metrics/` owns route preparation, CVRP hooks, scoring + preview, route geometry, and insertion ranking. +- `src/constraints/` owns one score rule per file plus `mod.rs` assembly. +- `src/data/data_seed/` owns deterministic city demo-data modules with grouped + visit files for scaled delivery counts. +- `src/api/` owns REST, DTO, and SSE surfaces. +- `src/solver/` owns retained-job runtime orchestration. +- `static/app/models/` owns frontend plan modeling. +- `static/app/ui/` owns browser layout and rendering helpers. + +Keep the canonical solution name `Plan`. Do not reintroduce `DeliveryPlan` or +`delivery_plan.rs`. + +## File Size Rule + +No source, test, frontend, config, or repo documentation file should reach 300 +lines. Split by responsibility before that point. Large generated/cache output +under `target/` and `.osm_cache/` is outside this rule. + +## Build And Validation Commands + +- `make help` shows the supported command surface. +- `make run-release` runs the app locally on `:7860`. +- `make test` runs Rust, frontend, and Playwright browser tests. +- `make test-e2e` runs the real browser Playwright smoke. +- `make space-build` builds the Docker image used by Hugging Face Spaces. +- `make space-run` builds and runs that image locally. +- `make ci-local` runs formatting, clippy, release build, standard tests, and + the Space Docker image build. +- `make test-live-road` runs the env-enabled road-network smoke test. +- `make pre-release` runs `ci-local` and the live road-network smoke. +- `cargo test` runs Rust unit and integration tests. +- `node --test tests/frontend_models.test.mjs` runs frontend model tests. + +Use the Makefile as the authoritative local workflow, matching the +`solverforge-hospital` Space/Docker validation standard. + +## No Suppression Policy + +Do not add warning suppressions, fallback compatibility branches, or unused +helper modules. If a split creates unused code, restructure the modules so each +compiled item is used by its crate. + +## Documentation Policy + +Keep `Cargo.toml`, `Cargo.lock`, `Makefile`, `Dockerfile`, `README.md`, +`WIREFRAME.md`, `AGENTS.md`, `docs/screenshot.png`, +`solverforge.app.toml`, `solver.toml`, `static/sf-config.json`, and the visible +API guide in +`static/app/ui/api-guide.mjs` aligned. + +When changing routes, solver policy, demo IDs, dependency sources, file layout, +or browser behavior, update the docs and screenshot in the same patch. Prefer +current-state documentation over planning language. + +## Testing Guidance + +Add Rust unit tests next to the behavior they protect. Add API integration +coverage under `tests/api_contract/` and shared integration helpers under +`tests/support/` only when every helper is used by the single +`tests/api_contract.rs` crate. Add frontend model tests in +`tests/frontend_models.test.mjs`, and browser-flow tests in `tests/e2e/`. + +Road-network tests should stay env-gated unless the test uses only cached or +straight-line behavior. Use `SOLVERFORGE_RUN_LIVE_TESTS=1` through +`make test-live-road` when validating live map/routing paths. + +## Runtime Notes + +`solver.toml` is embedded by `Plan` through the planning-solution macro. Treat +it as the solver policy source of truth. + +`Cargo.toml` currently uses Rust `1.95` and crates.io dependency declarations. +Keep every direct dependency declaration, `solverforge.app.toml`, `Cargo.lock`, +and docs truthful if dependency sources or versions change. + +The app serves stock `solverforge-ui` assets, local static app modules, and +Axum API routes from one process. Retained solver jobs are controlled through +REST and observed through SSE. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..ccf6a3e501616a4747e781439bc905ac1f2a3f65 --- /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 + +* **deliveries:** publish the SolverForge deliveries use case in the bundle. + +### Maintenance + +* **release:** align the bundled app with SolverForge 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..1e99407dc29402731e3a9d727d9ec7a0ef77cf9b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2673 @@ +# 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-deliveries" +version = "2.0.0" +dependencies = [ + "axum", + "http-body-util", + "parking_lot", + "rand 0.10.1", + "serde", + "serde_json", + "solverforge", + "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..f876fc4bbd2997f5de53dadafa6f26fcc1750a49 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "solverforge-deliveries" +version = "2.0.0" +edition = "2021" +rust-version = "1.95" +description = "Constraint optimizer built with SolverForge" + +[[bin]] +name = "solverforge_deliveries" +path = "src/main.rs" + +[dependencies] +solverforge = { version = "0.13.1", features = [ + "serde", + "console", + "verbose-logging", +] } +solverforge-ui = "0.6.5" +solverforge-maps = "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" +rand = "0.10.1" + +# Utilities +uuid = { version = "1.23.1", features = ["v4", "serde"] } +parking_lot = "0.12.5" + +[dev-dependencies] +http-body-util = "0.1.3" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..9d73e734e20e108d5854cf815f3bf74fa9ee5174 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Multi-stage build for solverforge-deliveries. +# +# 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-deliveries . + +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 + +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_deliveries ./solverforge_deliveries +COPY --from=builder /build/static/ ./static/ +COPY --from=builder /build/solver.toml ./solver.toml + +ENV PORT=7860 + +EXPOSE 7860 + +CMD ["./solverforge_deliveries"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..2b807e1a912afcb75cfbfe7e4b8d0d90471b42b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,282 @@ +# SolverForge Deliveries Makefile +# Rust + frontend + Space-oriented local build system. +# +# This app is validated for local development and Docker-based Hugging Face +# Space deployment. `ci-local` therefore includes a Docker image build. + +SHELL := /bin/sh +.SHELLFLAGS := -eu -c +unexport BASH_FUNC_mc%% + +# ============== Colors & Symbols ============== +GREEN := \033[92m +EMERALD := \033[38;2;16;185;129m +CYAN := \033[96m +YELLOW := \033[93m +RED := \033[91m +GRAY := \033[90m +BOLD := \033[1m +RESET := \033[0m + +CHECK := OK +CROSS := FAIL +ARROW := => +PROGRESS := .. + +# ============== Project Metadata ============== +APP_NAME := solverforge_deliveries +PACKAGE_NAME := solverforge-deliveries +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 Targets ============== +.PHONY: banner help doctor build build-release run run-release test test-rust \ + test-frontend-syntax test-frontend test-e2e test-live-road test-one lint fmt \ + fmt-check clippy check ci-local space-ci space-build space-run docker-build \ + docker-run pre-release release-ci release-info version clean watch require-node require-docker + +.DEFAULT_GOAL := help + +# ============== Banner ============== +banner: + @printf "$(EMERALD)$(BOLD) ____ _ _____\n" + @printf " / ___| ___ | |_ _____ _ __| ___|__ _ __ __ _ ___\n" + @printf " \\___ \\\\ / _ \\\\| \\\\ \\\\ / / _ \\\\ '__| |_ / _ \\\\| '__/ _\` |/ _ \\\\\n" + @printf " ___) | (_) | |\\\\ V / __/ | | _| (_) | | | (_| | __/\n" + @printf " |____/ \\\\___/|_| \\_/ \\___|_| |_| \\___/|_| \\__, |\\___|\n" + @printf " |___/$(RESET)\n" + @printf " $(GRAY)v$(VERSION)$(RESET) $(EMERALD)Deliveries demo build system$(RESET)\n\n" + +# ============== Environment Checks ============== +require-node: + @command -v node >/dev/null 2>&1 || (printf "$(RED)$(CROSS) node is required for frontend validation$(RESET)\n" && exit 1) + +require-docker: + @command -v docker >/dev/null 2>&1 || (printf "$(RED)$(CROSS) docker is required for Space/Docker targets$(RESET)\n" && exit 1) + +doctor: banner + @printf "$(CYAN)$(BOLD)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 "$(RED)$(CROSS) node not found$(RESET)\n"; missing=1; \ + fi; \ + if command -v docker >/dev/null 2>&1; then \ + printf "$(GREEN)$(CHECK) docker: $$(docker --version)$(RESET)\n"; \ + else \ + printf "$(YELLOW)! docker not found; Space/Docker targets will be unavailable$(RESET)\n"; \ + fi; \ + printf "$(GRAY)Docker build context: $(DOCKER_CONTEXT)$(RESET)\n"; \ + printf "$(GRAY)Default app port: $(PORT)$(RESET)\n"; \ + if [ $$missing -ne 0 ]; then exit 1; fi + @printf "\n" + +# ============== Build & Run ============== +build: banner + @printf "$(ARROW) $(BOLD)Building $(PACKAGE_NAME)...$(RESET)\n" + @cargo build --bin $(APP_NAME) && \ + printf "$(GREEN)$(CHECK) Debug build successful$(RESET)\n\n" || \ + (printf "$(RED)$(CROSS) Debug build failed$(RESET)\n\n" && exit 1) + +build-release: banner + @printf "$(ARROW) $(BOLD)Building release binary...$(RESET)\n" + @cargo build --release --bin $(APP_NAME) && \ + printf "$(GREEN)$(CHECK) Release build successful$(RESET)\n\n" || \ + (printf "$(RED)$(CROSS) Release build failed$(RESET)\n\n" && exit 1) + +run: + @printf "$(ARROW) Running $(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 Targets ============== +test: test-rust test-frontend test-e2e + @printf "\n$(GREEN)$(BOLD)$(CHECK) Standard validation passed$(RESET)\n\n" + +test-rust: banner + @printf "$(ARROW) $(BOLD)Running cargo test --quiet...$(RESET)\n" + @cargo test --quiet && \ + printf "\n$(GREEN)$(CHECK) Rust tests passed$(RESET)\n\n" || \ + (printf "\n$(RED)$(CROSS) Rust tests failed$(RESET)\n\n" && exit 1) + +test-frontend-syntax: require-node + @printf "$(PROGRESS) Checking frontend module syntax...\n" + @find static/app -name '*.mjs' -print0 | xargs -0 -n1 node --check && \ + printf "$(GREEN)$(CHECK) Frontend syntax checks passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Frontend syntax checks failed$(RESET)\n" && exit 1) + +test-frontend: test-frontend-syntax + @printf "$(PROGRESS) Running frontend tests...\n" + @node --test tests/frontend_models.test.mjs && \ + printf "$(GREEN)$(CHECK) Frontend tests passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Frontend tests failed$(RESET)\n" && exit 1) + +test-e2e: build-release require-node + @printf "$(PROGRESS) Running Playwright browser tests...\n" + @$(PLAYWRIGHT) test --config tests/e2e/playwright.config.js && \ + printf "$(GREEN)$(CHECK) Playwright browser tests passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Playwright browser tests failed$(RESET)\n" && exit 1) + +test-live-road: banner + @printf "$(ARROW) $(BOLD)Running live road-network route tests...$(RESET)\n" + @SOLVERFORGE_RUN_LIVE_TESTS=1 cargo test live_demo_locations_are_mutually_reachable_when_enabled -- --nocapture && \ + SOLVERFORGE_RUN_LIVE_TESTS=1 cargo test road_network_job_routes_work_when_live_tests_are_enabled -- --nocapture && \ + printf "\n$(GREEN)$(CHECK) Live road-network test passed$(RESET)\n\n" || \ + (printf "\n$(RED)$(CROSS) Live road-network test failed$(RESET)\n\n" && exit 1) + +test-one: + @printf "$(PROGRESS) Running test: $(YELLOW)$(TEST)$(RESET)\n" + @RUST_LOG=info cargo test $(TEST) -- --nocapture + +# ============== Lint & Format ============== +fmt: + @printf "$(PROGRESS) Formatting Rust code...\n" + @cargo fmt + @printf "$(GREEN)$(CHECK) Code formatted$(RESET)\n" + +fmt-check: + @printf "$(PROGRESS) Checking Rust formatting...\n" + @cargo fmt --check && \ + printf "$(GREEN)$(CHECK) Formatting valid$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Formatting issues found$(RESET)\n" && exit 1) + +clippy: + @printf "$(PROGRESS) Running clippy...\n" + @cargo clippy --all-targets -- -D warnings && \ + printf "$(GREEN)$(CHECK) Clippy passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Clippy warnings found$(RESET)\n" && exit 1) + +lint: fmt-check clippy test-frontend-syntax + @printf "\n$(GREEN)$(BOLD)$(CHECK) Lint checks passed$(RESET)\n\n" + +check: lint test + +# ============== Space & Docker ============== +docker-build: require-docker + @printf "$(PROGRESS) Building Docker image $(DOCKER_IMAGE)...\n" + @docker build -f "$(DOCKERFILE_PATH)" -t "$(DOCKER_IMAGE)" "$(DOCKER_CONTEXT)" && \ + printf "$(GREEN)$(CHECK) Docker image built$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Docker build failed$(RESET)\n" && exit 1) + +docker-run: require-docker + @printf "$(ARROW) Running $(DOCKER_IMAGE) on port $(PORT)...\n" + @docker run --rm -it -e PORT=$(PORT) -p $(PORT):$(PORT) "$(DOCKER_IMAGE)" + +space-build: docker-build + +space-run: space-build + @printf "$(GREEN)$(CHECK) Starting local container that mirrors the Space image$(RESET)\n" + @$(MAKE) docker-run --no-print-directory PORT=$(PORT) DOCKER_IMAGE=$(DOCKER_IMAGE) + +space-ci: ci-local + +# ============== CI & Release Validation ============== +ci-local: banner + @printf "$(CYAN)$(BOLD)Local 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: banner + @printf "$(CYAN)$(BOLD)Pre-Release Validation v$(VERSION)$(RESET)\n\n" + @$(MAKE) ci-local --no-print-directory + @printf "$(PROGRESS) Final step: live road-network smoke...\n" + @$(MAKE) test-live-road --no-print-directory + @printf "$(GREEN)$(BOLD)$(CHECK) Ready for publication or Space update$(RESET)\n\n" + +release-ci: ci-local + @printf "$(GREEN)$(BOLD)$(CHECK) Release CI passed for $(RELEASE_TAG)$(RESET)\n\n" + +release-info: + @printf "$(CYAN)Package:$(RESET) $(YELLOW)$(BOLD)$(PACKAGE_NAME)$(RESET)\n" + @printf "$(CYAN)Version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n" + @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n" + +# ============== Metadata & Cleanup ============== +version: + @printf "$(CYAN)Current version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n" + @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n" + @printf "$(CYAN)Default port:$(RESET) $(YELLOW)$(BOLD)$(PORT)$(RESET)\n" + +clean: + @printf "$(ARROW) Cleaning build artifacts...\n" + @cargo clean + @printf "$(GREEN)$(CHECK) Clean complete$(RESET)\n" + +watch: + @printf "$(ARROW) Watching and rerunning the app on port $(PORT)...\n" + @cargo watch --version >/dev/null 2>&1 || \ + (printf "$(RED)$(CROSS) cargo-watch is required for make watch$(RESET)\n" && exit 1) + @PORT=$(PORT) cargo watch -x "run --bin $(APP_NAME)" + +# ============== Help ============== +help: banner + @/bin/echo -e "$(CYAN)$(BOLD)Environment:$(RESET)" + @/bin/echo -e " $(GREEN)make doctor$(RESET) - Check local cargo/rustc/node readiness" + @/bin/echo -e "" + @/bin/echo -e "$(CYAN)$(BOLD)Build & Run:$(RESET)" + @/bin/echo -e " $(GREEN)make build$(RESET) - Build the app in debug mode" + @/bin/echo -e " $(GREEN)make build-release$(RESET) - Build the app in release mode" + @/bin/echo -e " $(GREEN)make run$(RESET) - Run 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, and Playwright tests" + @/bin/echo -e " $(GREEN)make test-rust$(RESET) - Run Rust tests only" + @/bin/echo -e " $(GREEN)make test-frontend$(RESET) - Run frontend syntax checks and tests" + @/bin/echo -e " $(GREEN)make test-e2e$(RESET) - Run Playwright browser tests" + @/bin/echo -e " $(GREEN)make test-live-road$(RESET) - Run the live road-network smoke test" + @/bin/echo -e " $(GREEN)make test-one TEST=name$(RESET) - Run a specific Rust test with output" + @/bin/echo -e " $(GREEN)make lint$(RESET) - Run fmt-check, clippy, and frontend syntax checks" + @/bin/echo -e " $(GREEN)make check$(RESET) - Run lint plus standard tests" + @/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 ci-local plus live road-network smoke" + @/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 fmt$(RESET) - Format Rust code" + @/bin/echo -e " $(GREEN)make release-info$(RESET) - Show package version and app-scoped release tag" + @/bin/echo -e " $(GREEN)make version$(RESET) - Show version and default port" + @/bin/echo -e " $(GREEN)make clean$(RESET) - Clean build artifacts" + @/bin/echo -e " $(GREEN)make watch$(RESET) - Watch source files and rerun the app" + @/bin/echo -e " $(GREEN)make help$(RESET) - Show this help message" + @/bin/echo -e "" + @/bin/echo -e "$(GRAY)Rust version required: $(RUST_VERSION)$(RESET)" + @/bin/echo -e "$(GRAY)Current version: v$(VERSION)$(RESET)" + @/bin/echo -e "$(GRAY)Release tag: $(RELEASE_TAG)$(RESET)" + @/bin/echo -e "$(GRAY)Default port: $(PORT)$(RESET)" + @/bin/echo -e "" diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1b48043cfce8ca5966136da455a8230984903d9d --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +--- +title: SolverForge Deliveries +emoji: 🚚 +colorFrom: green +colorTo: blue +sdk: docker +app_port: 7860 +pinned: false +license: apache-2.0 +short_description: SolverForge delivery-route optimization example +--- + +# SolverForge Deliveries + +![SolverForge Deliveries screenshot](docs/screenshot.png) + +`solverforge-deliveries` is a SolverForge vehicle-routing app with retained +jobs, route geometry, insertion recommendations, and a browser plan viewer. + +It answers one concrete question: + +"Given depots, vehicles, delivery stops, capacities, and time windows, which +vehicle should visit each delivery 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, maps, 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-deliveries`; version is declared in `Cargo.toml` +- Release binary: `solverforge_deliveries` +- Rust: `1.95` +- SolverForge runtime: `solverforge` `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 + +- `Delivery` is a problem fact: input data the solver reads but does not move. +- `Vehicle` is a planning entity: each vehicle owns one mutable route. +- `Vehicle.delivery_order` is the list planning variable: the sequence + SolverForge changes during construction and local search. +- `Plan` is the planning solution: it owns deliveries, vehicles, routing mode, + view state, and the current `HardSoftScore`. +- `RoutingMode` selects `road_network` or `straight_line` route geometry. + +The app ships three deterministic datasets: `PHILADELPHIA` with 82 deliveries, +`HARTFORD` with 50 deliveries, and `FIRENZE` with 80 deliveries. Each dataset +has ten vehicles and coherent capacity for the published stops. + +## Constraints + +Hard constraints: + +- Every delivery is assigned. +- Vehicle capacity is not exceeded. +- Vehicle routes respect delivery time windows. + +Soft constraints: + +- Total travel time is minimized. + +## 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` +- `POST /recommendations/delivery-insertions` + +`snapshot_revision={n}` is optional for snapshots, analysis, and routes. SSE +clients receive a bootstrap event and then live retained-job events. + +## Solver Policy + +`solver.toml` is embedded by `Plan` and is the runtime source of truth. + +- `list_clarke_wright` builds initial delivery routes. +- `list_k_opt` improves those routes before local search. +- Local search combines nearby list change/swap, reverse, k-opt, ruin, and + limited sublist moves over `Vehicle.delivery_order`. +- `late_acceptance` with an accepted-count forager keeps several candidate + moves alive per step. +- Solving stops after 30 seconds total or after 5 seconds without improvement. + +`road_network` mode uses `solverforge-maps` to load a graph and return route +geometry through `/jobs/{id}/routes`. `straight_line` mode is a fast draft and +testing path. + +## Validation + +Standard validation: + +```sh +make test +``` + +Full local validation: + +```sh +make ci-local +``` + +Live road-network smoke: + +```sh +make test-live-road +``` + +`make test` runs Rust tests, browserless frontend tests, and Playwright browser +tests. `make ci-local` adds formatting, clippy, release build, and Docker image +build. `make pre-release` runs `ci-local` plus the live road-network smoke. + +## 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/plan.rs` + The `Plan` solution, list-variable hook wiring, and routing mode. +3. `src/domain/delivery.rs` and `src/domain/vehicle.rs` + The problem fact and planning entity. +4. `src/domain/route_metrics/` + Route preparation, SolverForge CVRP hooks, preview scoring, route geometry, + and insertion ranking. +5. `src/constraints/mod.rs` and `src/constraints/*.rs` + The score model, one rule per file. +6. `src/data/data_seed/entrypoints.rs` + Public demo-data IDs and generator dispatch. +7. `src/data/data_seed/{philadelphia,hartford,firenze}/` + City depots and delivery coordinates. +8. `src/solver/service.rs` + Retained-job orchestration over `SolverManager`. +9. `src/api/routes.rs`, `src/api/dto.rs`, and `src/api/sse.rs` + HTTP routes, transport DTOs, and live-event streaming. +10. `static/app/main.mjs`, `static/app/models/`, and `static/app/ui/` + Browser controller, model normalization, maps, tables, and modals. + +## Project Shape + +- `src/domain/` + Planning model, domain types, route metrics, and model tests. +- `src/constraints/` + Incremental SolverForge scoring rules. +- `src/data/` + Deterministic city demo-data generators. +- `src/solver/` + Retained-job facade and runtime event payload formatting. +- `src/api/` + Axum routes, DTOs, errors, and SSE endpoint. +- `static/app/` + Browser modules built on stock `solverforge-ui` assets. +- `tests/api_contract/` + API integration coverage for catalog, jobs, lifecycle, SSE, and routes. +- `tests/e2e/` + Playwright browser tests for the served app. diff --git a/WIREFRAME.md b/WIREFRAME.md new file mode 100644 index 0000000000000000000000000000000000000000..3fbff971fb308877c405c4f5946b994cff2957b3 --- /dev/null +++ b/WIREFRAME.md @@ -0,0 +1,241 @@ +# solverforge-deliveries WIREFRAME + +This file is the architectural map for the deliveries 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, route 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. +- `docs/screenshot.png` + Current browser screenshot embedded by the README. + +## What This Repo Is Teaching + +This repo is a complete `solverforge-deliveries` `1.0.1` list-variable +SolverForge app for delivery routing. + +It shows how to combine: + +- a `Plan` solution with a list planning variable +- route-specific score rules +- SolverForge CVRP construction and local-search hooks +- `solverforge-maps` road-network preparation and route geometry +- retained jobs with snapshots, analysis, cancel, pause, resume, and SSE +- a browser plan viewer built on stock `solverforge-ui` assets + +## SolverForge Concepts In Plain Language + +- `Delivery` + Input stop data. The solver assigns delivery IDs into vehicle routes. +- `Vehicle` + Planning entity. Each vehicle owns one ordered `delivery_order` list. +- `Plan` + Planning solution. It holds deliveries, vehicles, score, routing mode, view + state, and prepared routing data. +- hard score + Missing assignments, capacity overage, late delivery seconds, and unreachable + route legs. +- soft score + Total travel seconds. +- 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/main.mjs` loads `static/sf-config.json`. +3. The app fetches `/demo-data/PHILADELPHIA`. +4. `PlanDto::from_plan()` serializes a refreshed transport plan with preview + state. +5. The browser normalizes the plan, renders summary cards, tables, timelines, + and a map. +6. When the user clicks Solve, the browser sends the current plan to + `POST /jobs`. +7. `src/api/routes.rs` deserializes the `PlanDto`, rebuilds a `Plan`, and calls + `prepare_plan()`. +8. `prepare_plan()` builds straight-line or road-network matrices and attaches + `PreparedVehicleRouting` to each vehicle. +9. `SolverService` starts a retained solve through `SolverManager`. +10. Solver events are converted by `src/solver/service/runtime_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. Road-network route geometry follows the `solverforge-maps` graph route, + projects each leg endpoint onto the nearest road segment, and stitches the + exact depot or delivery coordinate back in before encoding so each displayed + leg reaches the visible markers. + +## 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 construction heuristics 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 +│ Hospital-style local build, validation, and Space/Docker commands. +├── Dockerfile +│ Multi-stage Rust 1.95 Docker image for Hugging Face Spaces. +├── .dockerignore +│ Keeps build artifacts, git metadata, route cache, and Playwright output out +│ of the Docker context. +├── 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, `Plan`, entities, facts, preview structs, +│ │ and route metrics. +│ ├── constraints/ +│ │ Assignment, capacity, time-window, and travel-time score rules. +│ ├── data/ +│ │ Deterministic city seed data and generator entrypoints. +│ ├── solver/ +│ │ Retained-job service and runtime event payload formatting. +│ └── api/ +│ Axum routes, DTOs, and SSE endpoint. +├── static/ +│ ├── index.html +│ ├── sf-config.json +│ ├── generated/ui-model.json +│ └── app/ +│ Browser controller, plan models, and UI renderers. +└── tests/ + ├── api_contract.rs + │ Single integration-test crate composed from `tests/api_contract/*.rs`. + ├── api_contract/ + │ Catalog, job, lifecycle, SSE, and live-road modules. + ├── support/ + │ Shared integration-test helpers used by that single crate. + ├── e2e/ + │ Playwright browser tests for the served app. + └── frontend_models.test.mjs + Frontend model tests. +``` + +## Domain And Route Metrics + +`src/domain/plan.rs` stays small and macro-facing. It owns the `Plan` struct, +normalization, list shadow refresh, transport refresh, and the +`VrpSolution` implementation. + +Route-specific behavior lives under `src/domain/route_metrics/`: + +- `preparation.rs` + Builds matrices and per-vehicle prepared routing data. +- `cvrp_hooks.rs` + Supplies Clarke-Wright, k-opt, load, capacity, and route replacement hooks. +- `metrics.rs` + Computes per-vehicle route metrics. +- `scoring.rs` + Builds preview DTOs and aggregate hard/soft score components. +- `routes.rs` + Builds straight-line or road-network route geometry snapshots, including + edge-projected visual endpoints for road legs. +- `insertions.rs` + Ranks candidate insertion positions for one delivery. +- `types.rs` + Shared route metrics, snapshots, candidates, and routing trait types. + +This split keeps the public domain API stable while avoiding oversized files. + +## Demo Data + +`src/data/data_seed/entrypoints.rs` exposes three demo IDs: + +- `PHILADELPHIA` +- `HARTFORD` +- `FIRENZE` + +Each city has a small module with separate depot and grouped visit files. The +generator is deterministic. Every demo has ten vehicle depots, enough scaled +deliveries for those vehicles, reachable road-network coordinates, and enough +aggregate capacity before route ordering. + +## 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. + +The insertion endpoint, `/recommendations/delivery-insertions`, is app-specific. +It prepares the submitted plan, removes the requested delivery from any +existing route, evaluates candidate insert positions, and returns preview plans. + +## Frontend Layout + +`static/app/main.mjs` is the controller. It owns current plan state, retained +job state, route identity tracking, and event handlers. + +Supporting modules are split by responsibility: + +- `static/app/models/core.mjs` + Clone and normalize incoming plans. +- `static/app/models/preview.mjs` + Draft straight-line preview scoring. +- `static/app/models/timeline.mjs` + Vehicle and delivery rail models. +- `static/app/models/formatters.mjs` + Labels, icons, tones, clocks, and durations. +- `static/app/ui/layout.mjs` + Page shell and stock SolverForge UI component composition. +- `static/app/ui/overview.mjs` + Summary, route list, vehicle-id keyed route highlighting, map, and timeline + rendering. The tutorial uses ten distinct map colors for its ten fixed + vehicles. +- `static/app/ui/data-tables.mjs` + Read-only vehicle and delivery tables with delivery insertion recommendations. +- `static/app/ui/modals.mjs` + Analysis and insertion recommendation bodies. +- `static/app/ui/api-guide.mjs` + Visible API guide content. +- `static/app/ui/lifecycle.mjs` + Dataset markers and route-identity 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 test-live-road` +- `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. + +The file-size rule is part of the architecture: keep files below 300 lines and +split by responsibility before they become broad catch-all modules. diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..159fde57579d290c921038bd0c777d19bb8dae40 --- /dev/null +++ b/docs/screenshot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab699c0cc0a22f01ee41866fce787b3b049f24665fbb1f8a4a1e30434488510f +size 136472 diff --git a/solver.toml b/solver.toml new file mode 100644 index 0000000000000000000000000000000000000000..f56b1ff3350cdab71a1cf7064e4924ad50582873 --- /dev/null +++ b/solver.toml @@ -0,0 +1,77 @@ +environment_mode = "reproducible" +random_seed = 42 + +[termination] +seconds_spent_limit = 30 +unimproved_seconds_spent_limit = 5 + +[[phases]] +type = "construction_heuristic" +construction_heuristic_type = "list_clarke_wright" +entity_class = "Vehicle" +variable_name = "delivery_order" + +[[phases]] +type = "construction_heuristic" +construction_heuristic_type = "list_k_opt" +k = 2 +entity_class = "Vehicle" +variable_name = "delivery_order" + +[[phases]] +type = "local_search" + +[phases.acceptor] +type = "late_acceptance" +late_acceptance_size = 200 + +[phases.forager] +type = "accepted_count" +limit = 4 + +[phases.move_selector] +type = "union_move_selector" + +[[phases.move_selector.selectors]] +type = "nearby_list_change_move_selector" +max_nearby = 20 +entity_class = "Vehicle" +variable_name = "delivery_order" + +[[phases.move_selector.selectors]] +type = "nearby_list_swap_move_selector" +max_nearby = 20 +entity_class = "Vehicle" +variable_name = "delivery_order" + +[[phases.move_selector.selectors]] +type = "list_reverse_move_selector" +entity_class = "Vehicle" +variable_name = "delivery_order" + +[[phases.move_selector.selectors]] +type = "k_opt_move_selector" +k = 3 +min_segment_len = 1 +max_nearby = 10 +entity_class = "Vehicle" +variable_name = "delivery_order" + +[[phases.move_selector.selectors]] +type = "list_ruin_move_selector" +min_ruin_count = 2 +max_ruin_count = 5 +moves_per_step = 10 +entity_class = "Vehicle" +variable_name = "delivery_order" + +[[phases.move_selector.selectors]] +type = "limited_neighborhood" +selected_count_limit = 500 + +[phases.move_selector.selectors.selector] +type = "sublist_change_move_selector" +min_sublist_size = 1 +max_sublist_size = 3 +entity_class = "Vehicle" +variable_name = "delivery_order" diff --git a/solverforge.app.toml b/solverforge.app.toml new file mode 100644 index 0000000000000000000000000000000000000000..52b8e43efdc6357df5c440f0723a4d896fa9e797 --- /dev/null +++ b/solverforge.app.toml @@ -0,0 +1,62 @@ +[app] +name = "solverforge-deliveries" +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 = "PHILADELPHIA" +available_sizes = [ + "PHILADELPHIA", + "HARTFORD", + "FIRENZE", +] + +[solution] +name = "Plan" +score = "HardSoftScore" + +[[facts]] +name = "delivery" +plural = "deliveries" +kind = "problem_fact" + +[[entities]] +name = "vehicle" +plural = "vehicles" +kind = "planning_entity" + +[[variables]] +entity = "vehicle" +entity_plural = "vehicles" +field = "delivery_order" +kind = "list" +range = "" +elements = "deliveries" +allows_unassigned = false +enabled = true + +[[constraints]] +name = "all_deliveries_assigned" +module = "all_deliveries_assigned" +enabled = true + +[[constraints]] +name = "delivery_time_windows" +module = "delivery_time_windows" +enabled = true + +[[constraints]] +name = "total_travel_time" +module = "total_travel_time" +enabled = true + +[[constraints]] +name = "vehicle_capacity" +module = "vehicle_capacity" +enabled = true diff --git a/src/api/dto.rs b/src/api/dto.rs new file mode 100644 index 0000000000000000000000000000000000000000..5b780dd024f0564e4746fe9cc83f16e27a71660c --- /dev/null +++ b/src/api/dto.rs @@ -0,0 +1,242 @@ +//! Browser-facing JSON types for the deliveries API. +//! +//! The domain model contains SolverForge annotations and route-preparation +//! caches. DTOs keep the transport contract plain: strings for scores, +//! camelCase field names, and only data the browser can render or request +//! again. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use solverforge::{HardSoftScore, SolverSnapshot, SolverSnapshotAnalysis, SolverStatus}; + +use crate::domain::{DeliveryInsertionCandidate, Plan, RoutesSnapshot}; + +mod runtime; + +pub use runtime::{lifecycle_state_label, terminal_reason_label, TelemetryDto}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanDto { + /// Flattened domain fields let the browser reuse SolverForge's generic + /// model metadata while this app adds delivery-specific route previews. + #[serde(flatten)] + pub fields: Map, + #[serde(default)] + pub score: Option, +} + +#[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, 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, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobRoutesDto { + pub id: String, + pub job_id: String, + pub snapshot_revision: u64, + #[serde(flatten)] + pub routes: RoutesSnapshot, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeliveryInsertionRequestDto { + pub plan: PlanDto, + pub delivery_id: usize, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeliveryInsertionCandidateDto { + pub vehicle_id: usize, + pub vehicle_name: String, + pub insert_index: usize, + pub hard_score: i64, + pub soft_score: i64, + pub score: String, + pub delta_hard: i64, + pub delta_soft: i64, + pub preview_plan: PlanDto, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeliveryInsertionResponseDto { + pub delivery_id: usize, + pub candidates: Vec, +} + +impl PlanDto { + /// Converts a domain plan into the browser JSON shape. + pub fn from_plan(plan: &Plan) -> Self { + let plan = plan.refreshed_for_transport(); + let score = plan.score.as_ref().map(ToString::to_string); + let mut fields = match serde_json::to_value(plan).expect("failed to serialize plan") { + Value::Object(map) => map, + _ => Map::new(), + }; + fields.remove("score"); + + Self { fields, score } + } + + /// Rebuilds the SolverForge domain value from a browser request payload. + pub fn to_domain(&self) -> Result { + let mut fields = self.fields.clone(); + fields.insert("score".to_string(), Value::Null); + let mut plan: Plan = serde_json::from_value(Value::Object(fields))?; + plan.normalize(); + Ok(plan) + } +} + +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, + } + } +} + +impl JobRoutesDto { + pub fn new(job_id: usize, snapshot_revision: u64, routes: RoutesSnapshot) -> Self { + Self { + id: job_id.to_string(), + job_id: job_id.to_string(), + snapshot_revision, + routes, + } + } +} + +impl DeliveryInsertionCandidateDto { + /// Adds score strings to an insertion candidate returned by route metrics. + pub fn from_candidate(candidate: DeliveryInsertionCandidate) -> Self { + Self { + vehicle_id: candidate.vehicle_id, + vehicle_name: candidate.vehicle_name, + insert_index: candidate.insert_index, + hard_score: candidate.hard_score, + soft_score: candidate.soft_score, + score: HardSoftScore::of(candidate.hard_score, candidate.soft_score).to_string(), + delta_hard: candidate.delta_hard, + delta_soft: candidate.delta_soft, + preview_plan: PlanDto::from_plan(&candidate.preview_plan), + } + } +} + +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(), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/api/dto/runtime.rs b/src/api/dto/runtime.rs new file mode 100644 index 0000000000000000000000000000000000000000..50dc3e9ff43123d19b2ea63fd043e5ae8fad5610 --- /dev/null +++ b/src/api/dto/runtime.rs @@ -0,0 +1,83 @@ +use serde::Serialize; +use solverforge::{SolverLifecycleState, SolverTelemetry, SolverTerminalReason}; +use std::time::Duration; + +#[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, +} + +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, + ), + } + } +} + +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/dto/tests.rs b/src/api/dto/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..7b483c8c0179bc65ce2f1717329cfd4dd4331171 --- /dev/null +++ b/src/api/dto/tests.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn plan_dto_serializes_hard_soft_score_as_display_string() { + let mut plan = Plan::new("score check", Vec::new(), Vec::new()); + plan.score = Some(HardSoftScore::of(0, -335)); + + let dto = PlanDto::from_plan(&plan); + assert_eq!(dto.score.as_deref(), Some("0hard/-335soft")); + + let value = serde_json::to_value(&dto).expect("dto should serialize"); + assert_eq!(value["score"], Value::String("0hard/-335soft".to_string())); + assert!( + !value["score"].is_object(), + "score must not serialize as a JSON object" + ); +} + +#[test] +fn plan_dto_ignores_inbound_score() { + let mut fields = Map::new(); + fields.insert("name".to_string(), Value::String("spoofed".to_string())); + fields.insert( + "routingMode".to_string(), + Value::String("straight_line".to_string()), + ); + fields.insert("viewState".to_string(), Value::Object(Map::new())); + fields.insert("deliveries".to_string(), Value::Array(Vec::new())); + fields.insert("vehicles".to_string(), Value::Array(Vec::new())); + + let dto = PlanDto { + fields, + score: Some("0hard/-335soft".to_string()), + }; + let plan = dto.to_domain().expect("dto should deserialize"); + + assert_eq!(plan.score, None); +} diff --git a/src/api/errors.rs b/src/api/errors.rs new file mode 100644 index 0000000000000000000000000000000000000000..d5422e56792c9962a1226445e5097d6780099c79 --- /dev/null +++ b/src/api/errors.rs @@ -0,0 +1,35 @@ +//! Shared HTTP error mapping for runtime and routing failures. +//! +//! Keeping this out of `routes.rs` lets the handler file read as a tutorial +//! walkthrough of the public API instead of as a collection of mapping helpers. + +use axum::http::StatusCode; + +/// Parses the path segment used by stock retained-job routes. +pub(super) fn parse_job_id(id: &str) -> Result { + id.parse::().map_err(|_| StatusCode::NOT_FOUND) +} + +/// Maps retained-runtime errors onto HTTP statuses the stock UI understands. +pub(super) 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, + } +} + +/// Maps map/routing preparation errors onto client-facing route statuses. +pub(super) fn status_from_routing_error(error: solverforge_maps::RoutingError) -> StatusCode { + 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, + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..946e5a4be2d681a0d2aeab4eb70bfcc446746bb6 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,12 @@ +//! HTTP transport surface for the deliveries tutorial. +//! +//! The API layer stays intentionally thin: routes decode requests, DTOs define +//! the browser-visible JSON contract, and `SolverService` owns retained jobs. + +mod dto; +mod errors; +mod routes; +mod sse; + +pub use dto::PlanDto; +pub use routes::{router, AppState}; diff --git a/src/api/routes.rs b/src/api/routes.rs new file mode 100644 index 0000000000000000000000000000000000000000..6e7e086b2a23cd8bbe0435db94dd3f5d90f67ca0 --- /dev/null +++ b/src/api/routes.rs @@ -0,0 +1,288 @@ +//! HTTP routes for the deliveries tutorial app. +//! +//! Each handler follows the same beginner-friendly shape: +//! decode request -> prepare the domain model if needed -> call the retained +//! solver facade -> encode a DTO for the browser. + +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, DeliveryInsertionCandidateDto, DeliveryInsertionRequestDto, + DeliveryInsertionResponseDto, JobAnalysisDto, JobRoutesDto, JobSnapshotDto, JobSummaryDto, + PlanDto, +}; +use super::errors::{parse_job_id, status_from_routing_error, status_from_solver_error}; +use super::sse; +use crate::data::{generate, DemoData}; +use crate::domain::{build_routes_snapshot, prepare_plan, rank_delivery_insertions}; +use crate::solver::SolverService; + +/// Shared application state stored once inside Axum. +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() + } +} + +/// Registers the public HTTP surface used by the browser and tests. +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)) + .route( + "/recommendations/delivery-insertions", + post(recommend_delivery_insertions), + ) + .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: "SolverForge Deliveries", + version: env!("CARGO_PKG_VERSION"), + solver_engine: "SolverForge", + }) +} + +/// Lists the deterministic demo datasets accepted by `/demo-data/{id}`. +async fn list_demo_data() -> Json> { + Json(vec![ + DemoData::Philadelphia.id(), + DemoData::Hartford.id(), + DemoData::Firenze.id(), + ]) +} + +/// Materializes one demo plan and sends it through the same DTO as snapshots. +async fn get_demo_data(Path(id): Path) -> Result, StatusCode> { + let demo = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + let plan = generate(demo); + 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)?; + // Route matrices and shadow variables must be ready before SolverForge + // starts construction, because the list-variable hooks read them directly. + prepare_plan(&mut plan) + .await + .map_err(status_from_routing_error)?; + let id = state + .solver + .start_job(plan) + .map_err(status_from_solver_error)?; + Ok(Json(CreateJobResponse { id })) +} + +/// Returns the retained-job summary without requiring a snapshot payload. +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))) +} + +/// Stock alias used by the shared SolverForge UI job-status helpers. +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))) +} + +/// Runs exact score analysis against a retained snapshot revision. +async fn analyze_by_id( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let snapshot_analysis = state + .solver + .analyze_snapshot(&id, query.snapshot_revision) + .map_err(status_from_solver_error)?; + let analysis = analysis_response(&snapshot_analysis.analysis); + Ok(Json(JobAnalysisDto::from_snapshot_analysis( + &snapshot_analysis, + analysis, + ))) +} + +/// Builds route geometry for the exact retained snapshot the browser is viewing. +async fn get_routes( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let job_id = parse_job_id(&id)?; + let mut snapshot = state + .solver + .get_snapshot(&id, query.snapshot_revision) + .map_err(status_from_solver_error)?; + if snapshot + .solution + .vehicles + .iter() + .any(|vehicle| vehicle.prepared_routing.is_none()) + { + // Older snapshots can be reconstructed from transport data. If the + // transient routing cache is absent, rebuild it before drawing routes. + prepare_plan(&mut snapshot.solution) + .await + .map_err(status_from_routing_error)?; + } + let routes = build_routes_snapshot(&snapshot.solution) + .await + .map_err(status_from_routing_error)?; + Ok(Json(JobRoutesDto::new( + job_id, + snapshot.snapshot_revision, + routes, + ))) +} + +/// Requests a runtime-managed pause at the next safe solver point. +async fn pause_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.pause(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +/// Resumes a paused retained job. +async fn resume_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.resume(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +/// Cancels a live or paused retained job without deleting its final snapshot. +async fn cancel_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.cancel(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +/// Deletes a terminal retained job and its cached SSE bootstrap state. +async fn delete_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.delete(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Ranks candidate vehicle/position insertions for one delivery. +async fn recommend_delivery_insertions( + Json(request): Json, +) -> Result, StatusCode> { + let mut plan = request + .plan + .to_domain() + .map_err(|_| StatusCode::BAD_REQUEST)?; + if request.delivery_id >= plan.deliveries.len() { + return Err(StatusCode::BAD_REQUEST); + } + // Candidate scoring uses the same prepared data as real solving so the + // modal preview matches the constraints and route metrics. + prepare_plan(&mut plan) + .await + .map_err(status_from_routing_error)?; + let candidates = rank_delivery_insertions( + &plan, + request.delivery_id, + request.limit.unwrap_or(8).min(24), + ) + .await + .map_err(status_from_routing_error)? + .into_iter() + .map(DeliveryInsertionCandidateDto::from_candidate) + .collect(); + Ok(Json(DeliveryInsertionResponseDto { + delivery_id: request.delivery_id, + candidates, + })) +} diff --git a/src/api/sse.rs b/src/api/sse.rs new file mode 100644 index 0000000000000000000000000000000000000000..2106e82a06907c0ce47ab1f3019449e1130e05b8 --- /dev/null +++ b/src/api/sse.rs @@ -0,0 +1,50 @@ +//! Server-sent events for retained delivery solve jobs. +//! +//! A browser may connect after a job has already started. The stream therefore +//! sends one bootstrap status first, then forwards live events from the +//! retained job broadcaster. + +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 = 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(|msg| match msg { + Ok(json) => Some(Ok::<_, std::convert::Infallible>( + format!("data: {}\n\n", json).into_bytes(), + )), + // Broadcast channels can report that a slow browser missed events. The + // next retained snapshot/status request is still authoritative, so the + // stream drops that gap instead of failing the connection. + Err(_) => None, + }); + + let stream = bootstrap.chain(live); + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header("X-Accel-Buffering", "no") + .body(Body::from_stream(stream)) + .unwrap()) +} diff --git a/src/constraints/all_deliveries_assigned.rs b/src/constraints/all_deliveries_assigned.rs new file mode 100644 index 0000000000000000000000000000000000000000..e0e4d65876b24e4d4f5f1334a12fc82cb53c8f0e --- /dev/null +++ b/src/constraints/all_deliveries_assigned.rs @@ -0,0 +1,28 @@ +use crate::domain::{ + Delivery, Plan, PlanConstraintStreams, Vehicle, UNASSIGNED_DELIVERY_HARD_PENALTY, +}; +use solverforge::prelude::*; +use solverforge::stream::joiner::equal_bi; +use solverforge::IncrementalConstraint; + +/// HARD: every delivery must appear in some vehicle route. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .deliveries() + // The right side flattens every vehicle route into assigned delivery + // ids. A delivery that does not exist in that flattened stream is + // unassigned and receives the dominant hard penalty. + .if_not_exists(( + ConstraintFactory::::new() + .vehicles() + .flattened(|vehicle: &Vehicle| &vehicle.delivery_order), + equal_bi( + |delivery: &Delivery| delivery.id, + |assigned: &usize| *assigned, + ), + )) + .penalize(hard_weight(|_: &Delivery| { + HardSoftScore::of(UNASSIGNED_DELIVERY_HARD_PENALTY, 0) + })) + .named("All Deliveries Assigned") +} diff --git a/src/constraints/delivery_time_windows.rs b/src/constraints/delivery_time_windows.rs new file mode 100644 index 0000000000000000000000000000000000000000..02a4af535d63d327a588c9b1afb2f74b1de756ee --- /dev/null +++ b/src/constraints/delivery_time_windows.rs @@ -0,0 +1,16 @@ +use crate::domain::{Plan, PlanConstraintStreams, Vehicle}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: each vehicle route must respect delivery time windows. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .vehicles() + // Time-window work is precomputed as a vehicle shadow value, so this + // rule can stay incremental and read one scalar per changed route. + .filter(|vehicle: &Vehicle| vehicle.time_window_violation_seconds() > 0) + .penalize(hard_weight(|vehicle: &Vehicle| { + HardSoftScore::of(vehicle.time_window_violation_seconds(), 0) + })) + .named("Delivery Time Windows") +} diff --git a/src/constraints/mod.rs b/src/constraints/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..0eaf23b0b6d35161c0ba16381d1b9fa1d3ecd613 --- /dev/null +++ b/src/constraints/mod.rs @@ -0,0 +1,32 @@ +//! Constraint assembly for delivery routing. +//! +//! Each sibling file contributes one named rule. `create_constraints()` lists +//! them in the order we want beginners to see in score analysis output. + +use crate::domain::Plan; +use solverforge::prelude::*; + +pub use self::assemble::create_constraints; + +// @solverforge:begin constraint-modules +mod all_deliveries_assigned; +mod delivery_time_windows; +mod total_travel_time; +mod vehicle_capacity; +// @solverforge:end constraint-modules + +mod assemble { + use super::*; + + /// Collects the full scoring model used by `Plan`. + pub fn create_constraints() -> impl ConstraintSet { + // @solverforge:begin constraint-calls + ( + all_deliveries_assigned::constraint(), + vehicle_capacity::constraint(), + delivery_time_windows::constraint(), + total_travel_time::constraint(), + ) + // @solverforge:end constraint-calls + } +} diff --git a/src/constraints/total_travel_time.rs b/src/constraints/total_travel_time.rs new file mode 100644 index 0000000000000000000000000000000000000000..cafa91ffb5adf7ad224e9f364662b0d4835952ed --- /dev/null +++ b/src/constraints/total_travel_time.rs @@ -0,0 +1,11 @@ +use crate::domain::{Plan, PlanConstraintStreams, Vehicle}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// SOFT: prefer less total travel time across all routes. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .vehicles() + .penalize(|vehicle: &Vehicle| HardSoftScore::of(0, vehicle.total_travel_seconds())) + .named("Total Travel Time") +} diff --git a/src/constraints/vehicle_capacity.rs b/src/constraints/vehicle_capacity.rs new file mode 100644 index 0000000000000000000000000000000000000000..8321ec5c21ddd535941a0164bf93bb76f964a4eb --- /dev/null +++ b/src/constraints/vehicle_capacity.rs @@ -0,0 +1,16 @@ +use crate::domain::{Plan, PlanConstraintStreams, Vehicle}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: a vehicle's assigned demand cannot exceed its capacity. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .vehicles() + // Capacity overage is also a route shadow value. SolverForge updates it + // after list moves, and this constraint only scores positive excess. + .filter(|vehicle: &Vehicle| vehicle.capacity_overage() > 0) + .penalize(hard_weight(|vehicle: &Vehicle| { + HardSoftScore::of(vehicle.capacity_overage(), 0) + })) + .named("Vehicle Capacity") +} diff --git a/src/data/data_seed.rs b/src/data/data_seed.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d6c371b6d9e1eb03613b391529c02508f674bc8 --- /dev/null +++ b/src/data/data_seed.rs @@ -0,0 +1,16 @@ +//! Deterministic delivery demo-data modules. +//! +//! `entrypoints` owns the public dataset ids, while each city module owns its +//! depots and stops. The solver receives ordinary `Plan` values; there is no +//! hidden runtime data source behind these seeds. + +mod entrypoints; +mod firenze; +mod hartford; +mod philadelphia; +mod types; + +pub use entrypoints::{generate, DemoData}; + +#[cfg(test)] +mod tests; diff --git a/src/data/data_seed/entrypoints.rs b/src/data/data_seed/entrypoints.rs new file mode 100644 index 0000000000000000000000000000000000000000..f7c3c5dd4aab583dda940144f028888959f8afc4 --- /dev/null +++ b/src/data/data_seed/entrypoints.rs @@ -0,0 +1,134 @@ +//! Public dataset ids and generator dispatch for delivery demos. +//! +//! City-specific modules only contain depots and visit groups. This file turns +//! those static fixtures into normalized `Plan` values with vehicles, delivery +//! ids, routing mode, and deterministic service windows. + +use std::str::FromStr; + +use rand::rngs::StdRng; +use rand::{RngExt, SeedableRng}; + +use super::types::{LocationData, VEHICLE_NAMES}; +use super::{firenze, hartford, philadelphia}; +use crate::domain::{Delivery, Plan, RoutingMode, Vehicle}; + +#[derive(Debug, Clone, Copy)] +pub enum DemoData { + Philadelphia, + Hartford, + Firenze, +} + +impl DemoData { + pub fn id(self) -> &'static str { + match self { + DemoData::Philadelphia => "PHILADELPHIA", + DemoData::Hartford => "HARTFORD", + DemoData::Firenze => "FIRENZE", + } + } + + pub fn label(self) -> &'static str { + match self { + DemoData::Philadelphia => "Philadelphia", + DemoData::Hartford => "Hartford", + DemoData::Firenze => "Firenze", + } + } +} + +impl FromStr for DemoData { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.trim().to_uppercase().as_str() { + "PHILADELPHIA" => Ok(DemoData::Philadelphia), + "HARTFORD" => Ok(DemoData::Hartford), + "FIRENZE" => Ok(DemoData::Firenze), + _ => Err(()), + } + } +} + +pub fn generate(demo: DemoData) -> Plan { + match demo { + DemoData::Philadelphia => generate_demo_data( + demo, + 0, + philadelphia::DEPOTS, + philadelphia::VISIT_GROUPS, + 6 * 3600, + 36, + 48, + ), + DemoData::Hartford => generate_demo_data( + demo, + 1, + hartford::DEPOTS, + hartford::VISIT_GROUPS, + 6 * 3600, + 24, + 34, + ), + DemoData::Firenze => generate_demo_data( + demo, + 2, + firenze::DEPOTS, + firenze::VISIT_GROUPS, + 6 * 3600, + 38, + 52, + ), + } +} + +/// Builds one city plan from static stops and deterministic vehicle settings. +fn generate_demo_data( + demo: DemoData, + seed: u64, + depots: &[LocationData], + stop_groups: &[&[LocationData]], + departure_time: i64, + min_capacity: i32, + max_capacity: i32, +) -> Plan { + let mut rng = StdRng::seed_from_u64(seed); + let vehicles = depots + .iter() + .enumerate() + .map(|(idx, depot)| { + Vehicle::new( + idx, + VEHICLE_NAMES[idx % VEHICLE_NAMES.len()], + rng.random_range(min_capacity..=max_capacity), + depot.lat, + depot.lng, + departure_time, + ) + }) + .collect::>(); + + let deliveries = stop_groups + .iter() + .flat_map(|stops| stops.iter()) + .enumerate() + .map(|(idx, location)| { + let (kind, min_start_time, max_end_time, demand_range, service_range) = + location.customer_type.profile(); + Delivery::new( + idx, + location.name, + kind, + (location.lat, location.lng), + rng.random_range(demand_range.0..=demand_range.1), + (min_start_time, max_end_time), + rng.random_range(service_range.0..=service_range.1), + ) + }) + .collect::>(); + + let mut plan = Plan::new(demo.label(), deliveries, vehicles); + plan.routing_mode = RoutingMode::RoadNetwork; + plan +} diff --git a/src/data/data_seed/firenze.rs b/src/data/data_seed/firenze.rs new file mode 100644 index 0000000000000000000000000000000000000000..05fe94b289c5087ca535f010c7eb6c79cf39a796 --- /dev/null +++ b/src/data/data_seed/firenze.rs @@ -0,0 +1,8 @@ +use super::types::LocationData; + +mod depots; +mod visits; +mod visits_extra; + +pub(super) use depots::DEPOTS; +pub(super) const VISIT_GROUPS: &[&[LocationData]] = &[visits::VISITS, visits_extra::VISITS]; diff --git a/src/data/data_seed/firenze/depots.rs b/src/data/data_seed/firenze/depots.rs new file mode 100644 index 0000000000000000000000000000000000000000..fc89f71c11fda19bfcb735612806ebe035ebfb8f --- /dev/null +++ b/src/data/data_seed/firenze/depots.rs @@ -0,0 +1,64 @@ +use super::super::types::{CustomerType, LocationData}; + +pub(in crate::data::data_seed) const DEPOTS: &[LocationData] = &[ + LocationData { + name: "Centro Storico Depot", + lat: 43.7696, + lng: 11.2558, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Santa Maria Novella Depot", + lat: 43.7745, + lng: 11.2487, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Campo di Marte Depot", + lat: 43.7820, + lng: 11.2820, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Rifredi Depot", + lat: 43.7950, + lng: 11.2410, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Novoli Depot", + lat: 43.7880, + lng: 11.2220, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Gavinana Depot", + lat: 43.7520, + lng: 11.2680, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Mercato Centrale Depot", + lat: 43.7762, + lng: 11.2540, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Santa Croce Depot", + lat: 43.7688, + lng: 11.2620, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Santo Spirito Depot", + lat: 43.7665, + lng: 11.2470, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Careggi Depot", + lat: 43.8020, + lng: 11.2530, + customer_type: CustomerType::Business, + }, +]; diff --git a/src/data/data_seed/firenze/visits.rs b/src/data/data_seed/firenze/visits.rs new file mode 100644 index 0000000000000000000000000000000000000000..3db2dfa0e2aed8528e26305f5a39e66201aef7d4 --- /dev/null +++ b/src/data/data_seed/firenze/visits.rs @@ -0,0 +1,292 @@ +use super::super::types::{CustomerType, LocationData}; + +pub(in crate::data::data_seed) const VISITS: &[LocationData] = &[ + LocationData { + name: "Trattoria Mario", + lat: 43.7762, + lng: 11.2540, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Buca Mario", + lat: 43.7698, + lng: 11.2505, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Il Latini", + lat: 43.7705, + lng: 11.2495, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Osteria dell'Enoteca", + lat: 43.7680, + lng: 11.2545, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Trattoria Sostanza", + lat: 43.7735, + lng: 11.2470, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "All'Antico Vinaio", + lat: 43.7690, + lng: 11.2570, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Mercato Centrale", + lat: 43.7762, + lng: 11.2540, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Cibreo", + lat: 43.7702, + lng: 11.2670, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Ora d'Aria", + lat: 43.7710, + lng: 11.2610, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Buca Lapi", + lat: 43.7720, + lng: 11.2535, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Il Palagio", + lat: 43.7680, + lng: 11.2550, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Enoteca Pinchiorri", + lat: 43.7695, + lng: 11.2620, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "La Giostra", + lat: 43.7745, + lng: 11.2650, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Fishing Lab", + lat: 43.7693, + lng: 11.2563, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Trattoria Cammillo", + lat: 43.7665, + lng: 11.2520, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Palazzo Vecchio", + lat: 43.7693, + lng: 11.2563, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Uffizi Gallery", + lat: 43.7677, + lng: 11.2553, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Gucci Garden", + lat: 43.7692, + lng: 11.2556, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Ferragamo Museum", + lat: 43.7700, + lng: 11.2530, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Ospedale Santa Maria", + lat: 43.7830, + lng: 11.2690, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Universita degli Studi", + lat: 43.7765, + lng: 11.2555, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Palazzo Strozzi", + lat: 43.7706, + lng: 11.2515, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Biblioteca Nazionale", + lat: 43.7660, + lng: 11.2650, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Teatro del Maggio", + lat: 43.7780, + lng: 11.2470, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Palazzo Pitti", + lat: 43.7665, + lng: 11.2470, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Accademia Gallery", + lat: 43.7768, + lng: 11.2590, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Ospedale Meyer", + lat: 43.7910, + lng: 11.2520, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Polo Universitario", + lat: 43.7920, + lng: 11.2180, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Santo Spirito", + lat: 43.7665, + lng: 11.2470, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "San Frediano", + lat: 43.7680, + lng: 11.2420, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Santa Croce", + lat: 43.7688, + lng: 11.2620, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "San Lorenzo", + lat: 43.7755, + lng: 11.2540, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "San Marco", + lat: 43.7768, + lng: 11.2590, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Sant'Ambrogio", + lat: 43.7688, + lng: 11.2620, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Campo di Marte", + lat: 43.7820, + lng: 11.2820, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Novoli", + lat: 43.7880, + lng: 11.2220, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Rifredi", + lat: 43.7950, + lng: 11.2410, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Le Cure", + lat: 43.7890, + lng: 11.2580, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Careggi", + lat: 43.8020, + lng: 11.2530, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Peretola", + lat: 43.7960, + lng: 11.2050, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Isolotto", + lat: 43.7620, + lng: 11.2200, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Gavinana", + lat: 43.7520, + lng: 11.2680, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Galluzzo", + lat: 43.7400, + lng: 11.2480, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Porta Romana", + lat: 43.7610, + lng: 11.2560, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Bellosguardo", + lat: 43.7650, + lng: 11.2350, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Arcetri", + lat: 43.7500, + lng: 11.2530, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Fiesole", + lat: 43.8055, + lng: 11.2935, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Settignano", + lat: 43.7850, + lng: 11.3100, + customer_type: CustomerType::Residential, + }, +]; diff --git a/src/data/data_seed/firenze/visits_extra.rs b/src/data/data_seed/firenze/visits_extra.rs new file mode 100644 index 0000000000000000000000000000000000000000..9660b278415428d28be0995d57b79516d102ebfe --- /dev/null +++ b/src/data/data_seed/firenze/visits_extra.rs @@ -0,0 +1,196 @@ +use super::super::types::{CustomerType, LocationData}; + +pub(in crate::data::data_seed) const VISITS: &[LocationData] = &[ + LocationData { + name: "Mercato di Sant'Ambrogio", + lat: 43.7688, + lng: 11.2620, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Procacci", + lat: 43.7706, + lng: 11.2515, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "La Menagere", + lat: 43.7755, + lng: 11.2540, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Rivoire", + lat: 43.7693, + lng: 11.2563, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Gelateria dei Neri", + lat: 43.7688, + lng: 11.2620, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Trattoria Za Za", + lat: 43.7762, + lng: 11.2540, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Il Santo Bevitore", + lat: 43.7680, + lng: 11.2420, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Da Ruggero", + lat: 43.7610, + lng: 11.2560, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Perseus", + lat: 43.7890, + lng: 11.2580, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Coquinarius", + lat: 43.7720, + lng: 11.2535, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Santa Maria del Fiore", + lat: 43.7755, + lng: 11.2540, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Stazione Leopolda", + lat: 43.7780, + lng: 11.2470, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Fortezza da Basso", + lat: 43.7762, + lng: 11.2540, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Palazzo Medici Riccardi", + lat: 43.7765, + lng: 11.2555, + customer_type: CustomerType::Business, + }, + LocationData { + name: "San Lorenzo Market", + lat: 43.7762, + lng: 11.2540, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Santa Maria Novella", + lat: 43.7745, + lng: 11.2487, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Boboli Gardens Office", + lat: 43.7665, + lng: 11.2470, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Villa Bardini", + lat: 43.7660, + lng: 11.2650, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Careggi Hospital", + lat: 43.8020, + lng: 11.2530, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Coverciano Offices", + lat: 43.7850, + lng: 11.3100, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Firenze Airport Cargo", + lat: 43.7960, + lng: 11.2050, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Statuto", + lat: 43.7890, + lng: 11.2580, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Piazza Beccaria", + lat: 43.7688, + lng: 11.2620, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Coverciano", + lat: 43.7850, + lng: 11.3100, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Campo di Marte East", + lat: 43.7820, + lng: 11.2820, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Novoli South", + lat: 43.7880, + lng: 11.2220, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Rifredi South", + lat: 43.7950, + lng: 11.2410, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Careggi North", + lat: 43.8020, + lng: 11.2530, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Isolotto South", + lat: 43.7620, + lng: 11.2200, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Gavinana South", + lat: 43.7520, + lng: 11.2680, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Galluzzo Center", + lat: 43.7400, + lng: 11.2480, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Arcetri Hill", + lat: 43.7500, + lng: 11.2530, + customer_type: CustomerType::Residential, + }, +]; diff --git a/src/data/data_seed/hartford.rs b/src/data/data_seed/hartford.rs new file mode 100644 index 0000000000000000000000000000000000000000..05fe94b289c5087ca535f010c7eb6c79cf39a796 --- /dev/null +++ b/src/data/data_seed/hartford.rs @@ -0,0 +1,8 @@ +use super::types::LocationData; + +mod depots; +mod visits; +mod visits_extra; + +pub(super) use depots::DEPOTS; +pub(super) const VISIT_GROUPS: &[&[LocationData]] = &[visits::VISITS, visits_extra::VISITS]; diff --git a/src/data/data_seed/hartford/depots.rs b/src/data/data_seed/hartford/depots.rs new file mode 100644 index 0000000000000000000000000000000000000000..3458415b6453dc7f595c39a17bc1aeecb5d2d12c --- /dev/null +++ b/src/data/data_seed/hartford/depots.rs @@ -0,0 +1,64 @@ +use super::super::types::{CustomerType, LocationData}; + +pub(in crate::data::data_seed) const DEPOTS: &[LocationData] = &[ + LocationData { + name: "Downtown Hartford Depot", + lat: 41.7658, + lng: -72.6734, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Asylum Hill Depot", + lat: 41.7700, + lng: -72.6900, + customer_type: CustomerType::Business, + }, + LocationData { + name: "South End Depot", + lat: 41.7400, + lng: -72.6750, + customer_type: CustomerType::Business, + }, + LocationData { + name: "West End Depot", + lat: 41.7680, + lng: -72.7100, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Barry Square Depot", + lat: 41.7450, + lng: -72.6800, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Clay Arsenal Depot", + lat: 41.7750, + lng: -72.6850, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Science Center Depot", + lat: 41.7650, + lng: -72.6695, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Frog Hollow Depot", + lat: 41.7580, + lng: -72.6900, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Blue Hills Depot", + lat: 41.7850, + lng: -72.7050, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Charter Oak Depot", + lat: 41.7495, + lng: -72.6650, + customer_type: CustomerType::Business, + }, +]; diff --git a/src/data/data_seed/hartford/visits.rs b/src/data/data_seed/hartford/visits.rs new file mode 100644 index 0000000000000000000000000000000000000000..299955dbc602294b6a26e3f48672c9047ba62b24 --- /dev/null +++ b/src/data/data_seed/hartford/visits.rs @@ -0,0 +1,184 @@ +use super::super::types::{CustomerType, LocationData}; + +pub(in crate::data::data_seed) const VISITS: &[LocationData] = &[ + LocationData { + name: "Max Downtown", + lat: 41.7670, + lng: -72.6730, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Trumbull Kitchen", + lat: 41.7650, + lng: -72.6750, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Salute", + lat: 41.7630, + lng: -72.6740, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Peppercorns Grill", + lat: 41.7680, + lng: -72.6700, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Feng Asian Bistro", + lat: 41.7640, + lng: -72.6725, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "On20", + lat: 41.7655, + lng: -72.6728, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "First and Last Tavern", + lat: 41.7620, + lng: -72.7050, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Agave Grill", + lat: 41.7580, + lng: -72.6820, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Bear's Smokehouse", + lat: 41.7550, + lng: -72.6780, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "City Steam Brewery", + lat: 41.7630, + lng: -72.6750, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Travelers Tower", + lat: 41.7658, + lng: -72.6734, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Hartford Steam Boiler", + lat: 41.7680, + lng: -72.6700, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Aetna Building", + lat: 41.7700, + lng: -72.6900, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Connecticut Convention Center", + lat: 41.7615, + lng: -72.6820, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Hartford Hospital", + lat: 41.7547, + lng: -72.6858, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Connecticut Children's", + lat: 41.7560, + lng: -72.6850, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Trinity College", + lat: 41.7580, + lng: -72.6900, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Connecticut Science Center", + lat: 41.7650, + lng: -72.6695, + customer_type: CustomerType::Business, + }, + LocationData { + name: "West End Hartford", + lat: 41.7680, + lng: -72.7000, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Asylum Hill", + lat: 41.7720, + lng: -72.6850, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Frog Hollow", + lat: 41.7580, + lng: -72.6900, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Barry Square", + lat: 41.7450, + lng: -72.6800, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "South End", + lat: 41.7400, + lng: -72.6750, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Blue Hills", + lat: 41.7850, + lng: -72.7050, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Parkville", + lat: 41.7650, + lng: -72.7100, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Behind the Rocks", + lat: 41.7550, + lng: -72.7050, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Charter Oak", + lat: 41.7495, + lng: -72.6650, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Sheldon Charter Oak", + lat: 41.7510, + lng: -72.6700, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Clay Arsenal", + lat: 41.7750, + lng: -72.6850, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Upper Albany", + lat: 41.7780, + lng: -72.6950, + customer_type: CustomerType::Residential, + }, +]; diff --git a/src/data/data_seed/hartford/visits_extra.rs b/src/data/data_seed/hartford/visits_extra.rs new file mode 100644 index 0000000000000000000000000000000000000000..70adca3fb881973664a4d0c599bd0627d0eebfbf --- /dev/null +++ b/src/data/data_seed/hartford/visits_extra.rs @@ -0,0 +1,124 @@ +use super::super::types::{CustomerType, LocationData}; + +pub(in crate::data::data_seed) const VISITS: &[LocationData] = &[ + LocationData { + name: "The Place 2 Be", + lat: 41.7670, + lng: -72.6730, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Republic at the Linden", + lat: 41.7650, + lng: -72.6750, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Sorella", + lat: 41.7630, + lng: -72.6750, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Black-Eyed Sally's", + lat: 41.7580, + lng: -72.6820, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "The Russell Hartford", + lat: 41.7655, + lng: -72.6728, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Fiddleheads Cafe", + lat: 41.7580, + lng: -72.6900, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Bushnell Center", + lat: 41.7615, + lng: -72.6820, + customer_type: CustomerType::Business, + }, + LocationData { + name: "State Capitol", + lat: 41.7630, + lng: -72.6740, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Hartford Public Library", + lat: 41.7650, + lng: -72.6695, + customer_type: CustomerType::Business, + }, + LocationData { + name: "XL Center", + lat: 41.7658, + lng: -72.6734, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Union Station Hartford", + lat: 41.7680, + lng: -72.6700, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Real Art Ways", + lat: 41.7650, + lng: -72.7100, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Colt Gateway", + lat: 41.7495, + lng: -72.6650, + customer_type: CustomerType::Business, + }, + LocationData { + name: "South Green", + lat: 41.7550, + lng: -72.6780, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "North Meadows", + lat: 41.7750, + lng: -72.6850, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "South Meadows", + lat: 41.7400, + lng: -72.6750, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "West Hartford Line", + lat: 41.7680, + lng: -72.7000, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Southwest Hartford", + lat: 41.7550, + lng: -72.7050, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "North End", + lat: 41.7780, + lng: -72.6950, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Park Terrace", + lat: 41.7450, + lng: -72.6800, + customer_type: CustomerType::Residential, + }, +]; diff --git a/src/data/data_seed/philadelphia.rs b/src/data/data_seed/philadelphia.rs new file mode 100644 index 0000000000000000000000000000000000000000..05fe94b289c5087ca535f010c7eb6c79cf39a796 --- /dev/null +++ b/src/data/data_seed/philadelphia.rs @@ -0,0 +1,8 @@ +use super::types::LocationData; + +mod depots; +mod visits; +mod visits_extra; + +pub(super) use depots::DEPOTS; +pub(super) const VISIT_GROUPS: &[&[LocationData]] = &[visits::VISITS, visits_extra::VISITS]; diff --git a/src/data/data_seed/philadelphia/depots.rs b/src/data/data_seed/philadelphia/depots.rs new file mode 100644 index 0000000000000000000000000000000000000000..55caecde12d2ca355c2ff817abbe68a4129f91f0 --- /dev/null +++ b/src/data/data_seed/philadelphia/depots.rs @@ -0,0 +1,64 @@ +use super::super::types::{CustomerType, LocationData}; + +pub(in crate::data::data_seed) const DEPOTS: &[LocationData] = &[ + LocationData { + name: "Central Depot - City Hall", + lat: 39.9526, + lng: -75.1652, + customer_type: CustomerType::Business, + }, + LocationData { + name: "South Philly Depot", + lat: 39.9256, + lng: -75.1697, + customer_type: CustomerType::Business, + }, + LocationData { + name: "University City Depot", + lat: 39.9522, + lng: -75.1932, + customer_type: CustomerType::Business, + }, + LocationData { + name: "North Philly Depot", + lat: 39.9907, + lng: -75.1556, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Fishtown Depot", + lat: 39.9712, + lng: -75.1340, + customer_type: CustomerType::Business, + }, + LocationData { + name: "West Philly Depot", + lat: 39.9601, + lng: -75.2175, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Logan Square Depot", + lat: 39.9567, + lng: -75.1720, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Pennsport Depot", + lat: 39.9320, + lng: -75.1450, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Kensington Depot", + lat: 39.9850, + lng: -75.1280, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Spruce Hill Depot", + lat: 39.9530, + lng: -75.2100, + customer_type: CustomerType::Business, + }, +]; diff --git a/src/data/data_seed/philadelphia/visits.rs b/src/data/data_seed/philadelphia/visits.rs new file mode 100644 index 0000000000000000000000000000000000000000..6c8dc1d092021eee3989e4c3f7668956dafdc650 --- /dev/null +++ b/src/data/data_seed/philadelphia/visits.rs @@ -0,0 +1,298 @@ +use super::super::types::{CustomerType, LocationData}; + +pub(in crate::data::data_seed) const VISITS: &[LocationData] = &[ + LocationData { + name: "Reading Terminal Market", + lat: 39.9535, + lng: -75.1589, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Parc Restaurant", + lat: 39.9493, + lng: -75.1727, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Zahav", + lat: 39.9430, + lng: -75.1474, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Vetri Cucina", + lat: 39.9499, + lng: -75.1659, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Talula's Garden", + lat: 39.9470, + lng: -75.1709, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Fork", + lat: 39.9493, + lng: -75.1539, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Morimoto", + lat: 39.9488, + lng: -75.1559, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Vernick Food & Drink", + lat: 39.9508, + lng: -75.1718, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Friday Saturday Sunday", + lat: 39.9492, + lng: -75.1715, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Royal Izakaya", + lat: 39.9410, + lng: -75.1509, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Laurel", + lat: 39.9392, + lng: -75.1538, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Marigold Kitchen", + lat: 39.9533, + lng: -75.1920, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Comcast Center", + lat: 39.9543, + lng: -75.1690, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Liberty Place", + lat: 39.9520, + lng: -75.1685, + customer_type: CustomerType::Business, + }, + LocationData { + name: "BNY Mellon Center", + lat: 39.9505, + lng: -75.1660, + customer_type: CustomerType::Business, + }, + LocationData { + name: "One Liberty Place", + lat: 39.9520, + lng: -75.1685, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Aramark Tower", + lat: 39.9550, + lng: -75.1705, + customer_type: CustomerType::Business, + }, + LocationData { + name: "PSFS Building", + lat: 39.9521, + lng: -75.1602, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Three Logan Square", + lat: 39.9567, + lng: -75.1720, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Two Commerce Square", + lat: 39.9551, + lng: -75.1675, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Penn Medicine", + lat: 39.9500, + lng: -75.1930, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Children's Hospital", + lat: 39.9482, + lng: -75.1950, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Drexel University", + lat: 39.9566, + lng: -75.1899, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Temple University", + lat: 39.9812, + lng: -75.1554, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Jefferson Hospital", + lat: 39.9487, + lng: -75.1577, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Pennsylvania Hospital", + lat: 39.9445, + lng: -75.1545, + customer_type: CustomerType::Business, + }, + LocationData { + name: "FMC Tower", + lat: 39.9499, + lng: -75.1780, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Cira Centre", + lat: 39.9560, + lng: -75.1822, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Rittenhouse Square", + lat: 39.9496, + lng: -75.1718, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Washington Square West", + lat: 39.9468, + lng: -75.1545, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Society Hill", + lat: 39.9425, + lng: -75.1478, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Old City", + lat: 39.9510, + lng: -75.1450, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Northern Liberties", + lat: 39.9650, + lng: -75.1420, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Fishtown", + lat: 39.9712, + lng: -75.1340, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Queen Village", + lat: 39.9380, + lng: -75.1520, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Bella Vista", + lat: 39.9395, + lng: -75.1598, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Graduate Hospital", + lat: 39.9425, + lng: -75.1768, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Fairmount", + lat: 39.9680, + lng: -75.1750, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Spring Garden", + lat: 39.9620, + lng: -75.1620, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Art Museum Area", + lat: 39.9656, + lng: -75.1810, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Brewerytown", + lat: 39.9750, + lng: -75.1850, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "East Passyunk", + lat: 39.9310, + lng: -75.1605, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Point Breeze", + lat: 39.9285, + lng: -75.1780, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Pennsport", + lat: 39.9320, + lng: -75.1450, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Powelton Village", + lat: 39.9610, + lng: -75.1950, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Spruce Hill", + lat: 39.9530, + lng: -75.2100, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Cedar Park", + lat: 39.9490, + lng: -75.2200, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Kensington", + lat: 39.9850, + lng: -75.1280, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Port Richmond", + lat: 39.9870, + lng: -75.1120, + customer_type: CustomerType::Residential, + }, +]; diff --git a/src/data/data_seed/philadelphia/visits_extra.rs b/src/data/data_seed/philadelphia/visits_extra.rs new file mode 100644 index 0000000000000000000000000000000000000000..bec576fdd417aaa292813c348ae7b653c9ecf675 --- /dev/null +++ b/src/data/data_seed/philadelphia/visits_extra.rs @@ -0,0 +1,202 @@ +use super::super::types::{CustomerType, LocationData}; + +pub(in crate::data::data_seed) const VISITS: &[LocationData] = &[ + LocationData { + name: "Di Bruno Bros", + lat: 39.9496, + lng: -75.1718, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Federal Donuts Center City", + lat: 39.9492, + lng: -75.1715, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "High Street Philly", + lat: 39.9493, + lng: -75.1539, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Suraya", + lat: 39.9712, + lng: -75.1340, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Wm Mulherin's Sons", + lat: 39.9712, + lng: -75.1340, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "El Vez", + lat: 39.9499, + lng: -75.1659, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "Barbuzzo", + lat: 39.9493, + lng: -75.1727, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "The Love", + lat: 39.9508, + lng: -75.1718, + customer_type: CustomerType::Restaurant, + }, + LocationData { + name: "30th Street Station", + lat: 39.9560, + lng: -75.1822, + customer_type: CustomerType::Business, + }, + LocationData { + name: "University of Pennsylvania", + lat: 39.9500, + lng: -75.1930, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Franklin Institute", + lat: 39.9567, + lng: -75.1720, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Academy of Natural Sciences", + lat: 39.9567, + lng: -75.1720, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Kimmel Center", + lat: 39.9499, + lng: -75.1659, + customer_type: CustomerType::Business, + }, + LocationData { + name: "City Hall Annex", + lat: 39.9526, + lng: -75.1652, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Independence Hall", + lat: 39.9510, + lng: -75.1450, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Navy Yard Offices", + lat: 39.9256, + lng: -75.1697, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Penn's Landing", + lat: 39.9410, + lng: -75.1509, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Rodin Museum", + lat: 39.9656, + lng: -75.1810, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Barnes Foundation", + lat: 39.9656, + lng: -75.1810, + customer_type: CustomerType::Business, + }, + LocationData { + name: "Fitler Square", + lat: 39.9499, + lng: -75.1780, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Logan Square", + lat: 39.9567, + lng: -75.1720, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Callowhill", + lat: 39.9620, + lng: -75.1620, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Francisville", + lat: 39.9680, + lng: -75.1750, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Whitman", + lat: 39.9256, + lng: -75.1697, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Passyunk Square", + lat: 39.9310, + lng: -75.1605, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Girard Estates", + lat: 39.9285, + lng: -75.1780, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "West Powelton", + lat: 39.9610, + lng: -75.1950, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Mantua", + lat: 39.9610, + lng: -75.1950, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Walnut Hill", + lat: 39.9530, + lng: -75.2100, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Cobbs Creek", + lat: 39.9490, + lng: -75.2200, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Harrowgate", + lat: 39.9850, + lng: -75.1280, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Tacony", + lat: 39.9870, + lng: -75.1120, + customer_type: CustomerType::Residential, + }, + LocationData { + name: "Manayunk", + lat: 39.9750, + lng: -75.1850, + customer_type: CustomerType::Residential, + }, +]; diff --git a/src/data/data_seed/tests.rs b/src/data/data_seed/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..fd5b4845b3d08b906bb7940eea66f1677163fd92 --- /dev/null +++ b/src/data/data_seed/tests.rs @@ -0,0 +1,123 @@ +use super::*; +use crate::data::data_seed::types::LocationData; +use solverforge_maps::{BoundingBox, Coord, NetworkConfig, RoadNetwork}; + +#[test] +fn parses_demo_data_ids_case_insensitively() { + assert!(matches!( + "philadelphia".parse::(), + Ok(DemoData::Philadelphia) + )); + assert!(matches!( + "HARTFORD".parse::(), + Ok(DemoData::Hartford) + )); +} + +#[test] +fn generates_city_demo_plan() { + for (demo, expected_stops, expected_name) in [ + ( + DemoData::Philadelphia, + visit_count(philadelphia::VISIT_GROUPS), + "Philadelphia", + ), + ( + DemoData::Hartford, + visit_count(hartford::VISIT_GROUPS), + "Hartford", + ), + ( + DemoData::Firenze, + visit_count(firenze::VISIT_GROUPS), + "Firenze", + ), + ] { + let plan = generate(demo); + assert_eq!(plan.name, expected_name); + assert_eq!(plan.vehicles.len(), 10); + assert_eq!(plan.deliveries.len(), expected_stops); + assert!(plan + .vehicles + .iter() + .all(|vehicle| vehicle.delivery_order.is_empty())); + } +} + +fn visit_count(groups: &[&[LocationData]]) -> usize { + groups.iter().map(|group| group.len()).sum() +} + +#[test] +fn demo_delivery_counts_scale_with_ten_vehicles() { + assert_eq!(generate(DemoData::Philadelphia).deliveries.len(), 82); + assert_eq!(generate(DemoData::Hartford).deliveries.len(), 50); + assert_eq!(generate(DemoData::Firenze).deliveries.len(), 80); +} + +#[test] +fn demo_plans_have_enough_vehicle_capacity() { + for demo in [ + DemoData::Philadelphia, + DemoData::Hartford, + DemoData::Firenze, + ] { + let plan = generate(demo); + let total_capacity: i32 = plan.vehicles.iter().map(|vehicle| vehicle.capacity).sum(); + let total_demand: i32 = plan.deliveries.iter().map(|delivery| delivery.demand).sum(); + + assert!( + total_capacity >= total_demand, + "{demo:?} demo should be capacity-feasible before route ordering: capacity={total_capacity}, demand={total_demand}" + ); + } +} + +#[tokio::test] +async fn live_demo_locations_are_mutually_reachable_when_enabled() { + if std::env::var("SOLVERFORGE_RUN_LIVE_TESTS").ok().as_deref() != Some("1") { + return; + } + + for demo in [ + DemoData::Philadelphia, + DemoData::Hartford, + DemoData::Firenze, + ] { + let plan = generate(demo); + let mut named_coords = Vec::new(); + for delivery in &plan.deliveries { + named_coords.push(( + format!("delivery {}", delivery.label), + delivery.coord().unwrap(), + )); + } + for vehicle in &plan.vehicles { + named_coords.push(( + format!("depot {}", vehicle.name), + vehicle.depot_coord().unwrap(), + )); + } + + let coords: Vec = named_coords.iter().map(|(_, coord)| *coord).collect(); + let bbox = BoundingBox::from_coords(&coords).expand_for_routing(&coords); + let network = RoadNetwork::load_or_fetch(&bbox, &NetworkConfig::default(), None) + .await + .unwrap(); + let matrix = network.compute_matrix(&coords, None).await; + let unreachable = matrix + .unreachable_pairs() + .into_iter() + .map(|(from_idx, to_idx)| { + let from_name = &named_coords[from_idx].0; + let to_name = &named_coords[to_idx].0; + format!("{from_name} -> {to_name}") + }) + .collect::>(); + + assert!( + unreachable.is_empty(), + "{demo:?} has unreachable directed routes: {unreachable:?}" + ); + } +} diff --git a/src/data/data_seed/types.rs b/src/data/data_seed/types.rs new file mode 100644 index 0000000000000000000000000000000000000000..f91ed3d50919ed70e01b4e95e55272ef9c5f9a3c --- /dev/null +++ b/src/data/data_seed/types.rs @@ -0,0 +1,48 @@ +use crate::domain::DeliveryKind; + +pub(super) const VEHICLE_NAMES: [&str; 10] = [ + "Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliet", +]; + +#[derive(Clone, Copy)] +pub(super) struct LocationData { + pub name: &'static str, + pub lat: f64, + pub lng: f64, + pub customer_type: CustomerType, +} + +#[derive(Clone, Copy)] +pub(super) enum CustomerType { + Residential, + Business, + Restaurant, +} + +impl CustomerType { + pub(super) fn profile(self) -> (DeliveryKind, i64, i64, (i32, i32), (i64, i64)) { + match self { + CustomerType::Residential => ( + DeliveryKind::Residential, + 17 * 3600, + 20 * 3600, + (1, 2), + (5 * 60, 10 * 60), + ), + CustomerType::Business => ( + DeliveryKind::Business, + 9 * 3600, + 17 * 3600, + (3, 6), + (15 * 60, 30 * 60), + ), + CustomerType::Restaurant => ( + DeliveryKind::Restaurant, + 6 * 3600, + 10 * 3600, + (5, 10), + (20 * 60, 40 * 60), + ), + } + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..05a1509cab04d316f5ac6a843d3e07d4ba5eb6ec --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,10 @@ +//! Stable demo-data boundary for the deliveries app. +//! +//! Other layers should import `generate` and `DemoData` from here instead of +//! reaching into city-specific seed modules. That keeps the public data surface +//! small while the Philadelphia, Hartford, and Firenze fixtures can stay split +//! for readability. + +mod data_seed; + +pub use data_seed::{generate, DemoData}; diff --git a/src/domain/coord_value.rs b/src/domain/coord_value.rs new file mode 100644 index 0000000000000000000000000000000000000000..73b195e411e3280859a89e36886baf6ea8eb3759 --- /dev/null +++ b/src/domain/coord_value.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::hash::{Hash, Hasher}; + +#[derive(Copy, Clone, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CoordValue(pub f64); + +impl CoordValue { + pub fn get(self) -> f64 { + self.0 + } +} + +impl From for CoordValue { + fn from(value: f64) -> Self { + Self(value) + } +} + +impl fmt::Debug for CoordValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl PartialEq for CoordValue { + fn eq(&self, other: &Self) -> bool { + self.0.to_bits() == other.0.to_bits() + } +} + +impl Eq for CoordValue {} + +impl Hash for CoordValue { + fn hash(&self, state: &mut H) { + self.0.to_bits().hash(state); + } +} diff --git a/src/domain/delivery.rs b/src/domain/delivery.rs new file mode 100644 index 0000000000000000000000000000000000000000..f3e536a818a1080ebec14efbbe25b6af7b061041 --- /dev/null +++ b/src/domain/delivery.rs @@ -0,0 +1,95 @@ +//! Delivery problem facts. +//! +//! A delivery is input data, not something SolverForge mutates directly. The +//! solver places delivery ids into each vehicle's list variable. + +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; +use solverforge_maps::{Coord, RoutingError}; + +use super::CoordValue; + +/// A delivery stop that can be assigned into a vehicle route. +#[problem_fact] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Delivery { + #[planning_id] + pub id: usize, + pub label: String, + pub kind: DeliveryKind, + /// Latitude in decimal degrees, wrapped so derived equality stays stable. + pub lat: CoordValue, + /// Longitude in decimal degrees, wrapped so derived equality stays stable. + pub lng: CoordValue, + /// Load consumed from the assigned vehicle capacity. + pub demand: i32, + /// Earliest allowed service start, expressed as seconds after midnight. + pub min_start_time: i64, + /// Latest allowed service end, expressed as seconds after midnight. + pub max_end_time: i64, + /// Time spent at the stop after arrival. + pub service_duration: i64, +} + +/// Coarse stop type used to shape demo-data demand and UI icons. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeliveryKind { + Residential, + Business, + Restaurant, + #[default] + Other, +} + +impl Delivery { + /// Creates one problem fact from transport-friendly primitive values. + pub fn new( + id: usize, + label: impl Into, + kind: DeliveryKind, + coord: (f64, f64), + demand: i32, + time_window: (i64, i64), + service_duration: i64, + ) -> Self { + Self { + id, + label: label.into(), + kind, + lat: coord.0.into(), + lng: coord.1.into(), + demand, + min_start_time: time_window.0, + max_end_time: time_window.1, + service_duration, + } + } + + /// Converts the serialized coordinates into the map library's checked type. + pub fn coord(&self) -> Result { + Ok(Coord::try_new(self.lat.get(), self.lng.get())?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_delivery_construction() { + let fact = Delivery::new( + 3, + "Test stop", + DeliveryKind::Business, + (43.77, 11.25), + 4, + (9 * 3600, 17 * 3600), + 20 * 60, + ); + assert_eq!(fact.id, 3); + assert_eq!(fact.label, "Test stop"); + assert_eq!(fact.kind, DeliveryKind::Business); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..bae298c57398901ba48b2a1d83efbd5004ecf571 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,42 @@ +//! Planning-model manifest and domain-layer exports. +//! +//! `planning_model!` is the single SolverForge model boundary. It lists the +//! file-backed domain modules, exports the public names used by the rest of the +//! app, and keeps list-variable hooks close to the domain they describe. + +solverforge::planning_model! { + root = "src/domain"; + + // @solverforge:begin domain-exports + mod coord_value; + mod delivery; + mod plan; + mod preview; + mod vehicle; + + pub use coord_value::CoordValue; + pub use delivery::Delivery; + pub use delivery::DeliveryKind; + pub use plan::{Plan, PlanConstraintStreams}; + pub use preview::{ + DeliveryPreview, PlanPreview, PlanViewState, RoutingMode, TimelineView, VehiclePreview, + VehiclePreviewStop, + }; + pub use vehicle::Vehicle; + // @solverforge:end domain-exports + + mod route_metrics; + + pub use route_metrics::{ + build_routes_snapshot, delivery_clarke_wright_depot, delivery_element_load, + delivery_k_opt_depot, delivery_k_opt_feasible, delivery_route_capacity, + delivery_route_distance, evaluate_plan, get_delivery_route, prepare_plan, + preview_for_plan, rank_delivery_insertions, replace_delivery_route, + DeliveryInsertionCandidate, DeliveryRoutingSolution, PlanScoreComponents, + PreparedVehicleRouting, RouteLegGeometry, RouteLegSummary, RoutesSnapshot, + VehicleRouteMetrics, UNASSIGNED_DELIVERY_HARD_PENALTY, + }; +} + +#[cfg(test)] +mod plan_tests; diff --git a/src/domain/plan.rs b/src/domain/plan.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e0ccd62cbcb46232d44bce1934d053a1f77c0a9 --- /dev/null +++ b/src/domain/plan.rs @@ -0,0 +1,169 @@ +//! Planning solution for the delivery-routing problem. +//! +//! The `Plan` owns facts, planning entities, score, routing mode, and transient +//! prepared route matrices. It is both the solver input and the shape serialized +//! through the API after `PlanDto` flattens it. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use solverforge::cvrp::ProblemData; +use solverforge::prelude::*; + +// @solverforge:begin solution-imports +use super::route_metrics::preview_for_plan; +use super::{Delivery, PlanViewState, RoutingMode, Vehicle}; +// @solverforge:end solution-imports + +#[planning_solution( + constraints = "crate::constraints::create_constraints", + solver_toml = "../../solver.toml" +)] +#[shadow_variable_updates( + list_owner = "vehicles", + post_update_listener = "refresh_vehicle_route_shadows" +)] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Plan { + pub name: String, + #[serde(default)] + pub routing_mode: RoutingMode, + #[serde(default)] + pub view_state: PlanViewState, + // @solverforge:begin solution-collections + #[problem_fact_collection] + pub deliveries: Vec, + #[planning_entity_collection] + pub vehicles: Vec, + // @solverforge:end solution-collections + #[planning_score] + pub score: Option, + /// Transient CVRP matrices shared with SolverForge list-variable hooks. + /// + /// This is skipped in JSON because it is rebuilt from coordinates before a + /// solve or route-geometry request. + #[serde(skip, default)] + pub prepared_problem_data: Vec>, +} + +impl Plan { + /// Builds a normalized plan from facts and route-owning vehicles. + pub fn new(name: impl Into, deliveries: Vec, vehicles: Vec) -> Self { + let mut plan = Self { + name: name.into(), + routing_mode: RoutingMode::default(), + view_state: PlanViewState::default(), + deliveries, + vehicles, + score: None, + prepared_problem_data: Vec::new(), + }; + plan.normalize(); + plan + } + + /// Reassigns dense ids and clears transient routing caches after decoding. + /// + /// SolverForge list variables store delivery indexes. If transport data + /// arrived with older public ids, this maps route entries back onto the + /// current dense delivery positions before scoring. + pub fn normalize(&mut self) { + let delivery_id_map: HashMap = self + .deliveries + .iter() + .enumerate() + .map(|(idx, delivery)| (delivery.id, idx)) + .collect(); + + for (idx, delivery) in self.deliveries.iter_mut().enumerate() { + delivery.id = idx; + } + + for (idx, vehicle) in self.vehicles.iter_mut().enumerate() { + vehicle.id = idx; + vehicle.prepared_routing = None; + vehicle.delivery_order = vehicle + .delivery_order + .iter() + .filter_map(|old_id| delivery_id_map.get(old_id).copied()) + .collect(); + vehicle.refresh_route_shadows(); + } + self.prepared_problem_data.clear(); + } + + /// Removes one delivery id from every route before insertion previewing. + pub fn remove_delivery_assignments(&mut self, delivery_id: usize) { + for vehicle in &mut self.vehicles { + vehicle + .delivery_order + .retain(|assigned| *assigned != delivery_id); + } + } + + /// List-variable post-update hook used by SolverForge shadow variables. + pub fn refresh_vehicle_route_shadows(&mut self, vehicle_idx: usize) { + if let Some(vehicle) = self.vehicles.get_mut(vehicle_idx) { + vehicle.refresh_route_shadows(); + } + } + + /// Clones the plan and attaches the UI-facing route preview. + pub fn refreshed_for_transport(&self) -> Self { + let mut plan = self.clone(); + plan.view_state.preview = Some(preview_for_plan(&plan)); + plan + } +} + +impl solverforge::cvrp::VrpSolution for Plan { + /// Gives SolverForge's CVRP move selectors access to prepared matrices. + fn vehicle_data_ptr(&self, entity_idx: usize) -> *const ProblemData { + self.vehicles[entity_idx] + .prepared_routing + .as_ref() + .and_then(|prepared| self.prepared_problem_data.get(prepared.problem_data_index)) + .map(Arc::as_ptr) + .unwrap_or(std::ptr::null()) + } + + /// Reads the mutable list variable for one vehicle as visit ids. + fn vehicle_visits(&self, entity_idx: usize) -> &[usize] { + &self.vehicles[entity_idx].delivery_order + } + + /// Lets CVRP construction and local-search hooks replace one vehicle route. + fn vehicle_visits_mut(&mut self, entity_idx: usize) -> &mut Vec { + &mut self.vehicles[entity_idx].delivery_order + } + + /// Reports the number of route-owning planning entities. + fn vehicle_count(&self) -> usize { + self.vehicles.len() + } +} + +#[cfg(test)] +impl Plan { + pub(crate) fn test_has_list_variable() -> bool { + Self::__solverforge_has_list_variable() + } + + pub(crate) fn test_total_list_entities(plan: &Self) -> usize { + Self::__solverforge_total_list_entities(plan) + } + + pub(crate) fn test_total_list_elements(plan: &Self) -> usize { + Self::__solverforge_total_list_elements(plan) + } + + pub(crate) fn test_is_trivial(plan: &Self) -> bool { + Self::__solverforge_is_trivial(plan) + } + + pub(crate) fn test_phase_count(config: &solverforge::SolverConfig) -> usize { + Self::__solverforge_build_phases(config).phases().len() + } +} diff --git a/src/domain/plan_tests.rs b/src/domain/plan_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..40814c0609c22a4be2399e8cb76a7178ac11d981 --- /dev/null +++ b/src/domain/plan_tests.rs @@ -0,0 +1,243 @@ +use super::*; +use crate::data::{generate, DemoData}; +use crate::domain::{prepare_plan, DeliveryKind, UNASSIGNED_DELIVERY_HARD_PENALTY}; +use solverforge::{Director, ScoreDirector, SolverConfig, SolverEvent, SolverManager}; + +fn tiny_plan() -> Plan { + Plan::new( + "tiny", + vec![ + Delivery::new( + 0, + "A", + DeliveryKind::Residential, + (39.9526, -75.1652), + 1, + (8 * 3600, 18 * 3600), + 10 * 60, + ), + Delivery::new( + 1, + "B", + DeliveryKind::Business, + (39.9626, -75.1752), + 1, + (8 * 3600, 18 * 3600), + 10 * 60, + ), + ], + vec![Vehicle::new(0, "Van 1", 4, 39.9526, -75.1652, 8 * 3600)], + ) +} + +fn prepared_tiny_plan_with_route() -> Plan { + let mut plan = tiny_plan(); + plan.routing_mode = RoutingMode::StraightLine; + plan.vehicles[0].delivery_order = vec![0, 1]; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime") + .block_on(async { + prepare_plan(&mut plan) + .await + .expect("plan preparation should work"); + }); + plan +} + +#[test] +fn score_director_populates_vehicle_route_shadows() { + let plan = prepared_tiny_plan_with_route(); + assert_eq!( + plan.vehicles[0].route_total_demand, 0, + "prepared transport data should not eagerly populate solver shadows" + ); + + let mut director = ScoreDirector::with_descriptor( + plan, + crate::constraints::create_constraints(), + Plan::descriptor(), + Plan::entity_count, + ); + let score = director.calculate_score(); + let vehicle = &director.working_solution().vehicles[0]; + + assert_eq!(vehicle.total_assigned_demand(), 2); + assert_eq!(vehicle.capacity_overage(), 0); + assert!( + vehicle.total_travel_seconds() > 0, + "route travel should be maintained as a shadow value" + ); + assert_eq!(score.hard(), 0); +} + +#[test] +fn vehicle_route_shadows_refresh_after_list_variable_changes() { + let plan = prepared_tiny_plan_with_route(); + let mut director = ScoreDirector::with_descriptor( + plan, + crate::constraints::create_constraints(), + Plan::descriptor(), + Plan::entity_count, + ); + director.calculate_score(); + assert_eq!( + director.working_solution().vehicles[0].total_assigned_demand(), + 2 + ); + + director.before_variable_changed(0, 0); + director.working_solution_mut().vehicles[0] + .delivery_order + .clear(); + director.after_variable_changed(0, 0); + let score = director.calculate_score(); + + let vehicle = &director.working_solution().vehicles[0]; + assert_eq!(vehicle.total_assigned_demand(), 0); + assert_eq!(vehicle.total_travel_seconds(), 0); + assert_eq!(vehicle.time_window_violation_seconds(), 0); + assert_eq!(score.hard(), -(2 * UNASSIGNED_DELIVERY_HARD_PENALTY)); +} + +#[test] +fn generated_list_runtime_is_non_trivial_and_builds_routes() { + static MANAGER: SolverManager = SolverManager::new(); + + let mut plan = tiny_plan(); + plan.routing_mode = RoutingMode::StraightLine; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime") + .block_on(async { + prepare_plan(&mut plan) + .await + .expect("plan preparation should work"); + }); + + assert!( + Plan::test_has_list_variable(), + "delivery plan should expose a list variable" + ); + assert_eq!(Plan::test_total_list_entities(&plan), 1); + assert_eq!(Plan::test_total_list_elements(&plan), 2); + assert!( + !Plan::test_is_trivial(&plan), + "prepared plan should not be trivial" + ); + + let config = + SolverConfig::from_toml_str(include_str!("../../solver.toml")).expect("valid config"); + assert_eq!( + Plan::test_phase_count(&config), + 3, + "expected Clarke-Wright construction + list k-opt + local search" + ); + + let (job_id, mut receiver) = MANAGER.solve(plan).expect("solve should start"); + let mut saw_non_empty_best = false; + loop { + match receiver + .blocking_recv() + .expect("event stream should reach a terminal event") + { + SolverEvent::BestSolution { solution, .. } => { + if solution + .vehicles + .iter() + .any(|vehicle| !vehicle.delivery_order.is_empty()) + { + saw_non_empty_best = true; + MANAGER.cancel(job_id).expect("job cancel should succeed"); + } + } + SolverEvent::Completed { .. } | SolverEvent::Cancelled { .. } => break, + SolverEvent::Failed { error, .. } => { + panic!("solve unexpectedly failed: {error}"); + } + SolverEvent::Progress { .. } + | SolverEvent::PauseRequested { .. } + | SolverEvent::Paused { .. } + | SolverEvent::Resumed { .. } => {} + } + } + MANAGER + .delete(job_id) + .expect("completed test job should delete"); + + assert!( + saw_non_empty_best, + "expected a non-empty best solution before cancellation" + ); +} + +#[test] +fn seeded_philadelphia_plan_emits_a_non_empty_best_solution() { + static MANAGER: SolverManager = SolverManager::new(); + + let mut plan = generate(DemoData::Philadelphia); + plan.routing_mode = RoutingMode::StraightLine; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime") + .block_on(async { + prepare_plan(&mut plan) + .await + .expect("plan preparation should work"); + }); + + let (job_id, mut receiver) = MANAGER.solve(plan).expect("solve should start"); + let mut saw_non_empty_best = false; + let mut first_non_empty_best: Option = None; + loop { + match receiver + .blocking_recv() + .expect("event stream should reach a terminal event") + { + SolverEvent::BestSolution { solution, .. } => { + if solution + .vehicles + .iter() + .any(|vehicle| !vehicle.delivery_order.is_empty()) + { + saw_non_empty_best = true; + first_non_empty_best.get_or_insert(solution.clone()); + MANAGER.cancel(job_id).expect("job cancel should succeed"); + } + } + SolverEvent::Completed { .. } | SolverEvent::Cancelled { .. } => break, + SolverEvent::Failed { error, .. } => { + panic!("solve unexpectedly failed: {error}"); + } + SolverEvent::Progress { .. } + | SolverEvent::PauseRequested { .. } + | SolverEvent::Paused { .. } + | SolverEvent::Resumed { .. } => {} + } + } + MANAGER + .delete(job_id) + .expect("completed test job should delete"); + + assert!( + saw_non_empty_best, + "expected a non-empty best solution for the seeded Philadelphia plan" + ); + let best = first_non_empty_best.expect("should retain the first non-empty best solution"); + assert!( + best.vehicles + .iter() + .any(|vehicle| vehicle.delivery_order.len() > 1), + "expected at least one multi-stop route after construction" + ); + let director = ScoreDirector::with_descriptor( + best.clone(), + crate::constraints::create_constraints(), + Plan::descriptor(), + Plan::entity_count, + ); + assert_eq!(director.entity_count(0), Some(best.vehicles.len())); +} diff --git a/src/domain/preview.rs b/src/domain/preview.rs new file mode 100644 index 0000000000000000000000000000000000000000..7e1b0149bcd44d5c8738a7ead7c0e5b9d83a81f7 --- /dev/null +++ b/src/domain/preview.rs @@ -0,0 +1,105 @@ +//! Browser preview structs embedded in `Plan.view_state`. +//! +//! These values are derived from the domain plan before transport. They let the +//! frontend render route summaries and timelines without duplicating every +//! scoring rule. + +use serde::{Deserialize, Serialize}; + +/// Chooses whether routing uses fast straight lines or real road-network data. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoutingMode { + StraightLine, + #[default] + RoadNetwork, +} + +/// Selects which timeline rail the browser shows. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TimelineView { + #[default] + ByVehicle, + ByDelivery, +} + +/// UI-only state that travels with the plan between browser and backend. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanViewState { + #[serde(default)] + pub timeline_view: TimelineView, + pub selected_vehicle_id: Option, + pub selected_delivery_id: Option, + #[serde(default)] + pub preview: Option, +} + +/// Aggregate route and score preview for the full plan. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanPreview { + pub hard_score: i64, + pub soft_score: i64, + pub unassigned_delivery_ids: Vec, + pub vehicles: Vec, + pub deliveries: Vec, +} + +/// Per-vehicle route summary used by cards, lists, and timelines. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VehiclePreview { + pub vehicle_id: usize, + pub vehicle_name: String, + pub total_demand: i32, + pub capacity_overage: i32, + pub stop_count: usize, + pub total_travel_seconds: i64, + pub total_wait_seconds: i64, + pub total_service_seconds: i64, + pub total_late_seconds: i64, + pub start_time: i64, + pub end_time: i64, + pub stops: Vec, +} + +/// One delivery stop on a vehicle timeline. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VehiclePreviewStop { + pub delivery_id: usize, + pub label: String, + pub kind: String, + pub sequence: usize, + pub demand: i32, + pub min_start_time: i64, + pub max_end_time: i64, + pub arrival_time: i64, + pub service_start_time: i64, + pub departure_time: i64, + pub travel_seconds_from_previous: i64, + pub wait_seconds: i64, + pub late_seconds: i64, +} + +/// Per-delivery assignment summary used by data tables and timelines. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeliveryPreview { + pub delivery_id: usize, + pub label: String, + pub kind: String, + pub demand: i32, + pub min_start_time: i64, + pub max_end_time: i64, + pub service_duration: i64, + pub assigned_vehicle_id: Option, + pub assigned_vehicle_name: Option, + pub sequence: Option, + pub arrival_time: Option, + pub service_start_time: Option, + pub departure_time: Option, + pub late_seconds: Option, +} diff --git a/src/domain/route_metrics/cvrp_hooks.rs b/src/domain/route_metrics/cvrp_hooks.rs new file mode 100644 index 0000000000000000000000000000000000000000..1fb38738389608750603c09e4abc989139401b21 --- /dev/null +++ b/src/domain/route_metrics/cvrp_hooks.rs @@ -0,0 +1,185 @@ +use solverforge_maps::UNREACHABLE; + +use crate::domain::Plan; + +use super::helpers::{ + fallback_delivery_to_delivery, fallback_delivery_to_vehicle, fallback_vehicle_to_delivery, + normalized_travel_time, +}; +use super::types::DeliveryRoutingSolution; + +/// Depot token used by Clarke-Wright before it commits visits to vehicle routes. +pub fn delivery_clarke_wright_depot(solution: &S) -> usize { + virtual_depot_value(solution.delivery_plan(), 0) +} + +/// Per-vehicle depot token used by k-opt route reconnection. +pub fn delivery_k_opt_depot(solution: &S, entity_idx: usize) -> usize { + virtual_depot_value(solution.delivery_plan(), entity_idx) +} + +/// Reads travel time between delivery ids and virtual depot ids. +/// +/// Virtual depot ids are `deliveries.len() + vehicle_idx`. Prepared route +/// matrices are preferred; straight-line fallback keeps previews and tests +/// usable before full map-backed preparation has run. +pub fn delivery_route_distance( + solution: &S, + from: usize, + to: usize, +) -> i64 { + if from == to { + return 0; + } + + let plan = solution.delivery_plan(); + match ( + virtual_depot_entity(plan, from), + virtual_depot_entity(plan, to), + plan.deliveries.get(from), + plan.deliveries.get(to), + ) { + (Some(_), Some(_), _, _) => 0, + (Some(vehicle_idx), None, _, Some(_)) => depot_to_delivery_seconds(plan, vehicle_idx, to), + (None, Some(vehicle_idx), Some(_), _) => delivery_to_depot_seconds(plan, vehicle_idx, from), + (None, None, Some(_), Some(_)) => delivery_to_delivery_seconds(plan, from, to), + _ => 0, + } +} + +pub fn delivery_element_load(solution: &S, delivery_id: usize) -> i64 { + solution + .delivery_plan() + .deliveries + .get(delivery_id) + .map_or(0, |delivery| i64::from(delivery.demand)) +} + +/// Conservative route capacity used by list construction when assigning visits. +pub fn delivery_route_capacity(solution: &S) -> i64 { + let plan = solution.delivery_plan(); + plan.deliveries + .iter() + .map(|delivery| i64::from(delivery.demand)) + .sum::() + .max(1) +} + +/// Gives list-aware move selectors an owned copy of one vehicle route. +pub fn get_delivery_route(solution: &S, entity_idx: usize) -> Vec { + solution.vehicle_visits(entity_idx).to_vec() +} + +/// Replaces one route after construction or a k-opt/list move accepts it. +pub fn replace_delivery_route( + solution: &mut S, + entity_idx: usize, + route: Vec, +) { + *solution.vehicle_visits_mut(entity_idx) = route; +} + +/// Guards k-opt proposals with the same route feasibility signals the app scores. +/// +/// The hook rejects unknown delivery ids, unreachable legs, and routes that +/// would miss a delivery time window after travel, waiting, and service time. +pub fn delivery_k_opt_feasible( + solution: &S, + entity_idx: usize, + route: &[usize], +) -> bool { + let plan = solution.delivery_plan(); + let Some(prepared) = plan + .vehicles + .get(entity_idx) + .and_then(|vehicle| vehicle.prepared_routing.as_ref()) + else { + return true; + }; + + let mut current = prepared.vehicle_departure_time; + let mut previous: Option = None; + + for &delivery_id in route { + if delivery_id >= prepared.demands.len() { + return false; + } + let travel = match previous { + Some(previous_id) => prepared.travel_times[previous_id][delivery_id], + None => prepared.depot_to_delivery_seconds[delivery_id], + }; + if travel == UNREACHABLE { + return false; + } + + current += normalized_travel_time(travel); + let (min_start, max_end) = prepared.time_windows[delivery_id]; + if current < min_start { + current = min_start; + } + + current += prepared.service_durations[delivery_id]; + if current > max_end { + return false; + } + previous = Some(delivery_id); + } + + true +} + +fn virtual_depot_value(plan: &Plan, entity_idx: usize) -> usize { + plan.deliveries.len().saturating_add(entity_idx) +} + +fn virtual_depot_entity(plan: &Plan, value: usize) -> Option { + value + .checked_sub(plan.deliveries.len()) + .filter(|&entity_idx| entity_idx < plan.vehicles.len()) +} + +fn depot_to_delivery_seconds(plan: &Plan, vehicle_idx: usize, delivery_id: usize) -> i64 { + plan.vehicles + .get(vehicle_idx) + .and_then(|vehicle| vehicle.prepared_routing.as_ref()) + .and_then(|prepared| { + prepared + .depot_to_delivery_seconds + .get(delivery_id) + .copied() + }) + .map(normalized_travel_time) + .or_else(|| fallback_vehicle_to_delivery(plan, vehicle_idx, delivery_id)) + .unwrap_or(0) +} + +fn delivery_to_depot_seconds(plan: &Plan, vehicle_idx: usize, delivery_id: usize) -> i64 { + plan.vehicles + .get(vehicle_idx) + .and_then(|vehicle| vehicle.prepared_routing.as_ref()) + .and_then(|prepared| { + prepared + .delivery_to_depot_seconds + .get(delivery_id) + .copied() + }) + .map(normalized_travel_time) + .or_else(|| fallback_delivery_to_vehicle(plan, vehicle_idx, delivery_id)) + .unwrap_or(0) +} + +fn delivery_to_delivery_seconds(plan: &Plan, from: usize, to: usize) -> i64 { + plan.vehicles + .iter() + .find_map(|vehicle| vehicle.prepared_routing.as_ref()) + .and_then(|prepared| { + prepared + .travel_times + .get(from) + .and_then(|row| row.get(to)) + .copied() + }) + .map(normalized_travel_time) + .or_else(|| fallback_delivery_to_delivery(plan, from, to)) + .unwrap_or(0) +} diff --git a/src/domain/route_metrics/helpers.rs b/src/domain/route_metrics/helpers.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf5bb3c3ee18b8e108c7bd426655bcc476fedd6f --- /dev/null +++ b/src/domain/route_metrics/helpers.rs @@ -0,0 +1,106 @@ +use solverforge_maps::{haversine_distance, BoundingBox, Coord, RoutingError, UNREACHABLE}; + +use crate::domain::{Delivery, Plan}; + +use super::types::RouteBounds; + +const DEFAULT_SPEED_KMPH: f64 = 50.0; + +pub(super) fn meters_to_seconds(meters: i64) -> i64 { + let meters_per_second = DEFAULT_SPEED_KMPH * 1000.0 / 3600.0; + (meters as f64 / meters_per_second).round() as i64 +} + +pub(super) fn normalized_travel_time(seconds: i64) -> i64 { + if seconds == UNREACHABLE { + super::types::UNREACHABLE_HARD_PENALTY + } else { + seconds.max(0) + } +} + +pub(super) fn normalized_distance(meters: i64) -> i64 { + if meters == UNREACHABLE { + 0 + } else { + meters.max(0) + } +} + +pub(super) fn build_delivery_distance_matrix(delivery_coords: &[Coord]) -> Vec> { + delivery_coords + .iter() + .map(|from| { + delivery_coords + .iter() + .map(|to| haversine_distance(*from, *to).round() as i64) + .collect() + }) + .collect() +} + +pub(super) fn build_travel_time_matrix(delivery_coords: &[Coord]) -> Vec> { + delivery_coords + .iter() + .map(|from| { + delivery_coords + .iter() + .map(|to| meters_to_seconds(haversine_distance(*from, *to).round() as i64)) + .collect() + }) + .collect() +} + +pub(super) fn delivery_coords(plan: &Plan) -> Result, RoutingError> { + plan.deliveries + .iter() + .map(Delivery::coord) + .collect::, _>>() +} + +pub(super) fn route_bounds(plan: &Plan) -> Result, RoutingError> { + if plan.deliveries.is_empty() && plan.vehicles.is_empty() { + return Ok(None); + } + + let mut coords = delivery_coords(plan)?; + for vehicle in &plan.vehicles { + coords.push(vehicle.depot_coord()?); + } + let bbox = BoundingBox::from_coords(&coords); + Ok(Some(RouteBounds { + south_west: [bbox.min_lat, bbox.min_lng], + north_east: [bbox.max_lat, bbox.max_lng], + })) +} + +pub(super) fn straight_line_leg(from: Coord, to: Coord) -> (i64, i64) { + let meters = haversine_distance(from, to).round() as i64; + (meters_to_seconds(meters), meters) +} + +pub(super) fn fallback_vehicle_to_delivery( + plan: &Plan, + vehicle_idx: usize, + delivery_id: usize, +) -> Option { + let depot = plan.vehicles.get(vehicle_idx)?.depot_coord().ok()?; + let delivery = plan.deliveries.get(delivery_id)?.coord().ok()?; + Some(straight_line_leg(depot, delivery).0) +} + +pub(super) fn fallback_delivery_to_vehicle( + plan: &Plan, + vehicle_idx: usize, + delivery_id: usize, +) -> Option { + let delivery = plan.deliveries.get(delivery_id)?.coord().ok()?; + let depot = plan.vehicles.get(vehicle_idx)?.depot_coord().ok()?; + Some(straight_line_leg(delivery, depot).0) +} + +pub(super) fn fallback_delivery_to_delivery(plan: &Plan, from: usize, to: usize) -> Option { + let from = plan.deliveries.get(from)?.coord().ok()?; + let to = plan.deliveries.get(to)?.coord().ok()?; + Some(straight_line_leg(from, to).0) +} diff --git a/src/domain/route_metrics/insertions.rs b/src/domain/route_metrics/insertions.rs new file mode 100644 index 0000000000000000000000000000000000000000..a48d88ed7b268fa36f079ec391abcf1c1c1381e0 --- /dev/null +++ b/src/domain/route_metrics/insertions.rs @@ -0,0 +1,66 @@ +use std::cmp::Ordering; + +use solverforge_maps::RoutingError; + +use crate::domain::Plan; + +use super::preparation::prepare_plan; +use super::scoring::evaluate_plan; +use super::types::DeliveryInsertionCandidate; + +/// Ranks interactive insertion positions without running a full solve. +/// +/// The selected delivery is removed from the current plan, the same routing +/// data used by solving is prepared, then every vehicle/position insertion is +/// preview-scored and sorted by feasibility first and travel quality second. +pub async fn rank_delivery_insertions( + plan: &Plan, + delivery_id: usize, + limit: usize, +) -> Result, RoutingError> { + let mut base_plan = plan.clone(); + base_plan.normalize(); + base_plan.remove_delivery_assignments(delivery_id); + prepare_plan(&mut base_plan).await?; + + let base_score = evaluate_plan(&base_plan); + let mut candidates = Vec::new(); + + for vehicle_idx in 0..base_plan.vehicles.len() { + let vehicle_name = base_plan.vehicles[vehicle_idx].name.clone(); + for insert_index in 0..=base_plan.vehicles[vehicle_idx].delivery_order.len() { + let mut candidate_plan = base_plan.clone(); + candidate_plan.vehicles[vehicle_idx] + .delivery_order + .insert(insert_index, delivery_id); + let score = evaluate_plan(&candidate_plan); + + candidates.push(DeliveryInsertionCandidate { + vehicle_id: candidate_plan.vehicles[vehicle_idx].id, + vehicle_name: vehicle_name.clone(), + insert_index, + hard_score: score.hard_score(), + soft_score: score.soft_score(), + delta_hard: score.hard_score() - base_score.hard_score(), + delta_soft: score.soft_score() - base_score.soft_score(), + preview_plan: candidate_plan, + }); + } + } + + candidates.sort_by(compare_candidates); + candidates.truncate(limit); + Ok(candidates) +} + +fn compare_candidates( + left: &DeliveryInsertionCandidate, + right: &DeliveryInsertionCandidate, +) -> Ordering { + right + .hard_score + .cmp(&left.hard_score) + .then_with(|| right.soft_score.cmp(&left.soft_score)) + .then_with(|| left.insert_index.cmp(&right.insert_index)) + .then_with(|| left.vehicle_id.cmp(&right.vehicle_id)) +} diff --git a/src/domain/route_metrics/metrics.rs b/src/domain/route_metrics/metrics.rs new file mode 100644 index 0000000000000000000000000000000000000000..272af2b3a28e46d7ccc1144faec1e1457d24e930 --- /dev/null +++ b/src/domain/route_metrics/metrics.rs @@ -0,0 +1,152 @@ +//! Route metric calculation shared by delivery constraints and previews. +//! +//! SolverForge mutates delivery order lists. This module walks those lists in +//! route order and turns them into arrival times, capacity usage, lateness, and +//! travel totals that multiple constraints can score consistently. + +use solverforge_maps::UNREACHABLE; + +use crate::domain::{Plan, Vehicle}; + +use super::helpers::{normalized_distance, normalized_travel_time, straight_line_leg}; +use super::types::{RouteStopMetrics, VehicleRouteMetrics}; + +pub(super) fn metrics_for_vehicle(plan: &Plan, vehicle: &Vehicle) -> VehicleRouteMetrics { + let mut metrics = VehicleRouteMetrics { + vehicle_id: vehicle.id, + start_time: vehicle.departure_time, + end_time: vehicle.departure_time, + ..VehicleRouteMetrics::default() + }; + + let mut current_time = vehicle.departure_time; + let mut previous_delivery_id = None; + + for (sequence, &delivery_id) in vehicle.delivery_order.iter().enumerate() { + let Some(delivery) = plan.deliveries.get(delivery_id) else { + continue; + }; + + metrics.total_demand += delivery.demand; + let (travel_seconds, travel_meters, unreachable) = + leg_from_previous(plan, vehicle, previous_delivery_id, delivery_id); + metrics.total_travel_seconds += travel_seconds; + metrics.total_distance_meters += travel_meters; + metrics.unreachable_legs += usize::from(unreachable); + + // Time windows are modeled around the moment service finishes. Waiting + // is allowed, but leaving after `max_end_time` contributes lateness. + let arrival_time = current_time.saturating_add(travel_seconds); + let service_start_time = arrival_time.max(delivery.min_start_time); + let wait_seconds = service_start_time.saturating_sub(arrival_time); + let departure_time = service_start_time.saturating_add(delivery.service_duration); + let late_seconds = departure_time.saturating_sub(delivery.max_end_time).max(0); + + metrics.total_wait_seconds += wait_seconds; + metrics.total_service_seconds += delivery.service_duration; + metrics.total_late_seconds += late_seconds; + metrics.end_time = departure_time; + metrics.stops.push(RouteStopMetrics { + delivery_id, + sequence, + arrival_time, + service_start_time, + departure_time, + travel_seconds_from_previous: travel_seconds, + wait_seconds, + late_seconds, + }); + + current_time = departure_time; + previous_delivery_id = Some(delivery_id); + } + + if let Some(last_delivery_id) = previous_delivery_id { + let (return_seconds, return_meters, unreachable) = + leg_to_depot(plan, vehicle, last_delivery_id); + metrics.total_travel_seconds += return_seconds; + metrics.total_distance_meters += return_meters; + metrics.unreachable_legs += usize::from(unreachable); + metrics.end_time = metrics.end_time.saturating_add(return_seconds); + } + + metrics.capacity_overage = (metrics.total_demand - vehicle.capacity).max(0); + metrics +} + +fn leg_from_previous( + plan: &Plan, + vehicle: &Vehicle, + previous_delivery_id: Option, + current_delivery_id: usize, +) -> (i64, i64, bool) { + match previous_delivery_id { + Some(previous_delivery_id) => prepared_or_straight_line_delivery_leg( + plan, + vehicle, + previous_delivery_id, + current_delivery_id, + ), + None => prepared_or_straight_line_depot_leg(plan, vehicle, current_delivery_id), + } +} + +fn prepared_or_straight_line_delivery_leg( + plan: &Plan, + vehicle: &Vehicle, + from_delivery_id: usize, + to_delivery_id: usize, +) -> (i64, i64, bool) { + if let Some(prepared) = &vehicle.prepared_routing { + let seconds = prepared.travel_times[from_delivery_id][to_delivery_id]; + let meters = prepared.distance_matrix[from_delivery_id][to_delivery_id]; + return ( + normalized_travel_time(seconds), + normalized_distance(meters), + seconds == UNREACHABLE, + ); + } + + let from = plan.deliveries[from_delivery_id].coord().expect("valid coord"); + let to = plan.deliveries[to_delivery_id].coord().expect("valid coord"); + let (seconds, meters) = straight_line_leg(from, to); + (seconds, meters, false) +} + +fn prepared_or_straight_line_depot_leg( + plan: &Plan, + vehicle: &Vehicle, + delivery_id: usize, +) -> (i64, i64, bool) { + if let Some(prepared) = &vehicle.prepared_routing { + let seconds = prepared.depot_to_delivery_seconds[delivery_id]; + let meters = prepared.depot_to_delivery_meters[delivery_id]; + return ( + normalized_travel_time(seconds), + normalized_distance(meters), + seconds == UNREACHABLE, + ); + } + + let from = vehicle.depot_coord().expect("valid coord"); + let to = plan.deliveries[delivery_id].coord().expect("valid coord"); + let (seconds, meters) = straight_line_leg(from, to); + (seconds, meters, false) +} + +fn leg_to_depot(plan: &Plan, vehicle: &Vehicle, delivery_id: usize) -> (i64, i64, bool) { + if let Some(prepared) = &vehicle.prepared_routing { + let seconds = prepared.delivery_to_depot_seconds[delivery_id]; + let meters = prepared.delivery_to_depot_meters[delivery_id]; + return ( + normalized_travel_time(seconds), + normalized_distance(meters), + seconds == UNREACHABLE, + ); + } + + let from = plan.deliveries[delivery_id].coord().expect("valid coord"); + let to = vehicle.depot_coord().expect("valid coord"); + let (seconds, meters) = straight_line_leg(from, to); + (seconds, meters, false) +} diff --git a/src/domain/route_metrics/mod.rs b/src/domain/route_metrics/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..c3e0143724f9df065b1f06f08ede3cfff89e2b69 --- /dev/null +++ b/src/domain/route_metrics/mod.rs @@ -0,0 +1,26 @@ +mod cvrp_hooks; +mod helpers; +mod insertions; +mod metrics; +mod preparation; +mod routes; +mod scoring; +mod types; + +pub use cvrp_hooks::{ + delivery_clarke_wright_depot, delivery_element_load, delivery_k_opt_depot, + delivery_k_opt_feasible, delivery_route_capacity, delivery_route_distance, get_delivery_route, + replace_delivery_route, +}; +pub use insertions::rank_delivery_insertions; +pub use preparation::prepare_plan; +pub use routes::build_routes_snapshot; +pub use scoring::{evaluate_plan, preview_for_plan}; +pub use types::{ + DeliveryInsertionCandidate, DeliveryRoutingSolution, PlanScoreComponents, + PreparedVehicleRouting, RouteLegGeometry, RouteLegSummary, RoutesSnapshot, VehicleRouteMetrics, + UNASSIGNED_DELIVERY_HARD_PENALTY, +}; + +#[cfg(test)] +mod tests; diff --git a/src/domain/route_metrics/preparation.rs b/src/domain/route_metrics/preparation.rs new file mode 100644 index 0000000000000000000000000000000000000000..149b25cc437265b87d1ba785b63b05f404ec0d96 --- /dev/null +++ b/src/domain/route_metrics/preparation.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; + +use solverforge::cvrp::ProblemData; +use solverforge_maps::{ + haversine_distance, BoundingBox, Coord, NetworkConfig, RoadNetwork, RoutingError, UNREACHABLE, +}; + +use crate::domain::{Plan, RoutingMode}; + +use super::helpers::{ + build_delivery_distance_matrix, build_travel_time_matrix, delivery_coords, meters_to_seconds, +}; +use super::types::PreparedVehicleRouting; + +/// Builds the routing data SolverForge should read during a solve. +/// +/// This is the boundary between transport/domain data and the hot scoring path: +/// route ids are normalized, delivery matrices are computed once, per-vehicle +/// depot legs are attached, and the list-variable hooks can then score from +/// cached `ProblemData` instead of resolving maps during every move. +pub async fn prepare_plan(plan: &mut Plan) -> Result<(), RoutingError> { + plan.normalize(); + let delivery_coords = delivery_coords(plan)?; + let delivery_distance_matrix = build_delivery_distance_matrix(&delivery_coords); + let delivery_demands: Vec = plan + .deliveries + .iter() + .map(|delivery| delivery.demand) + .collect(); + let delivery_time_windows: Vec<(i64, i64)> = plan + .deliveries + .iter() + .map(|delivery| (delivery.min_start_time, delivery.max_end_time)) + .collect(); + let delivery_service_durations: Vec = plan + .deliveries + .iter() + .map(|delivery| delivery.service_duration) + .collect(); + + let depot_routing = match plan.routing_mode { + RoutingMode::StraightLine => DepotRoutingData::straight_line(plan, &delivery_coords)?, + RoutingMode::RoadNetwork => DepotRoutingData::road_network(plan, &delivery_coords).await?, + }; + + plan.prepared_problem_data.clear(); + for (vehicle_idx, vehicle) in plan.vehicles.iter_mut().enumerate() { + plan.prepared_problem_data.push(Arc::new(ProblemData { + capacity: vehicle.capacity as i64, + depot: 0, + demands: delivery_demands.clone(), + distance_matrix: delivery_distance_matrix.clone(), + time_windows: delivery_time_windows.clone(), + service_durations: delivery_service_durations.clone(), + travel_times: depot_routing.delivery_travel_times.clone(), + vehicle_departure_time: vehicle.departure_time, + })); + vehicle.prepared_routing = Some(PreparedVehicleRouting { + problem_data_index: vehicle_idx, + capacity: vehicle.capacity as i64, + demands: delivery_demands.clone(), + distance_matrix: delivery_distance_matrix.clone(), + time_windows: delivery_time_windows.clone(), + service_durations: delivery_service_durations.clone(), + travel_times: depot_routing.delivery_travel_times.clone(), + vehicle_departure_time: vehicle.departure_time, + depot_to_delivery_seconds: depot_routing.depot_to_delivery_seconds[vehicle_idx].clone(), + delivery_to_depot_seconds: depot_routing.delivery_to_depot_seconds[vehicle_idx].clone(), + depot_to_delivery_meters: depot_routing.depot_to_delivery_meters[vehicle_idx].clone(), + delivery_to_depot_meters: depot_routing.delivery_to_depot_meters[vehicle_idx].clone(), + }); + } + + Ok(()) +} + +#[derive(Clone, Debug)] +struct DepotRoutingData { + delivery_travel_times: Vec>, + depot_to_delivery_seconds: Vec>, + delivery_to_depot_seconds: Vec>, + depot_to_delivery_meters: Vec>, + delivery_to_depot_meters: Vec>, +} + +impl DepotRoutingData { + fn straight_line(plan: &Plan, delivery_coords: &[Coord]) -> Result { + let delivery_travel_times = build_travel_time_matrix(delivery_coords); + let mut depot_to_delivery_seconds = Vec::with_capacity(plan.vehicles.len()); + let mut delivery_to_depot_seconds = Vec::with_capacity(plan.vehicles.len()); + let mut depot_to_delivery_meters = Vec::with_capacity(plan.vehicles.len()); + let mut delivery_to_depot_meters = Vec::with_capacity(plan.vehicles.len()); + + for vehicle in &plan.vehicles { + let depot = vehicle.depot_coord()?; + let mut outbound_seconds = Vec::with_capacity(delivery_coords.len()); + let mut inbound_seconds = Vec::with_capacity(delivery_coords.len()); + let mut outbound_meters = Vec::with_capacity(delivery_coords.len()); + let mut inbound_meters = Vec::with_capacity(delivery_coords.len()); + for coord in delivery_coords { + let meters = haversine_distance(depot, *coord).round() as i64; + let seconds = meters_to_seconds(meters); + outbound_seconds.push(seconds); + inbound_seconds.push(seconds); + outbound_meters.push(meters); + inbound_meters.push(meters); + } + depot_to_delivery_seconds.push(outbound_seconds); + delivery_to_depot_seconds.push(inbound_seconds); + depot_to_delivery_meters.push(outbound_meters); + delivery_to_depot_meters.push(inbound_meters); + } + + Ok(Self { + delivery_travel_times, + depot_to_delivery_seconds, + delivery_to_depot_seconds, + depot_to_delivery_meters, + delivery_to_depot_meters, + }) + } + + async fn road_network(plan: &Plan, delivery_coords: &[Coord]) -> Result { + let mut all_coords = delivery_coords.to_vec(); + for vehicle in &plan.vehicles { + all_coords.push(vehicle.depot_coord()?); + } + + if all_coords.is_empty() { + return Ok(Self { + delivery_travel_times: Vec::new(), + depot_to_delivery_seconds: Vec::new(), + delivery_to_depot_seconds: Vec::new(), + depot_to_delivery_meters: Vec::new(), + delivery_to_depot_meters: Vec::new(), + }); + } + + let bbox = BoundingBox::from_coords(&all_coords).expand_for_routing(&all_coords); + let network = RoadNetwork::load_or_fetch(&bbox, &NetworkConfig::default(), None).await?; + let matrix = network.compute_matrix(&all_coords, None).await; + + let delivery_count = delivery_coords.len(); + let mut delivery_travel_times = vec![vec![0_i64; delivery_count]; delivery_count]; + for (i, row) in delivery_travel_times.iter_mut().enumerate() { + for (j, cell) in row.iter_mut().enumerate() { + *cell = matrix.get(i, j).unwrap_or(UNREACHABLE); + } + } + + let mut depot_to_delivery_seconds = Vec::with_capacity(plan.vehicles.len()); + let mut delivery_to_depot_seconds = Vec::with_capacity(plan.vehicles.len()); + let mut depot_to_delivery_meters = Vec::with_capacity(plan.vehicles.len()); + let mut delivery_to_depot_meters = Vec::with_capacity(plan.vehicles.len()); + + for vehicle_idx in 0..plan.vehicles.len() { + let depot_idx = delivery_count + vehicle_idx; + let depot = plan.vehicles[vehicle_idx].depot_coord()?; + let mut outbound_seconds = Vec::with_capacity(delivery_count); + let mut inbound_seconds = Vec::with_capacity(delivery_count); + let mut outbound_meters = Vec::with_capacity(delivery_count); + let mut inbound_meters = Vec::with_capacity(delivery_count); + for (delivery_idx, delivery_coord) in delivery_coords.iter().enumerate() { + outbound_seconds.push(matrix.get(depot_idx, delivery_idx).unwrap_or(UNREACHABLE)); + inbound_seconds.push(matrix.get(delivery_idx, depot_idx).unwrap_or(UNREACHABLE)); + let meters = haversine_distance(depot, *delivery_coord).round() as i64; + outbound_meters.push(meters); + inbound_meters.push(meters); + } + depot_to_delivery_seconds.push(outbound_seconds); + delivery_to_depot_seconds.push(inbound_seconds); + depot_to_delivery_meters.push(outbound_meters); + delivery_to_depot_meters.push(inbound_meters); + } + + Ok(Self { + delivery_travel_times, + depot_to_delivery_seconds, + delivery_to_depot_seconds, + depot_to_delivery_meters, + delivery_to_depot_meters, + }) + } +} diff --git a/src/domain/route_metrics/routes.rs b/src/domain/route_metrics/routes.rs new file mode 100644 index 0000000000000000000000000000000000000000..1d3aa95d651840924c4bd104634c355233a1a237 --- /dev/null +++ b/src/domain/route_metrics/routes.rs @@ -0,0 +1,235 @@ +use solverforge_maps::{ + encode_polyline, haversine_distance, BoundingBox, Coord, NetworkConfig, RoadNetwork, + RoutingError, +}; + +use crate::domain::{Plan, RoutingMode}; + +use super::helpers::{delivery_coords, meters_to_seconds, route_bounds}; +use super::metrics::metrics_for_vehicle; +use super::types::{RouteLegGeometry, RouteLegSummary, RoutesSnapshot}; + +/// Builds browser route geometry for one already-selected solution snapshot. +/// +/// This is separate from `prepare_plan`: preparation builds matrices for +/// scoring, while `/jobs/{id}/routes` turns the retained snapshot into encoded +/// map geometry the UI can draw. +pub async fn build_routes_snapshot(plan: &Plan) -> Result { + let bounds = route_bounds(plan)?; + let vehicles = match plan.routing_mode { + RoutingMode::StraightLine => build_straight_line_routes(plan)?, + RoutingMode::RoadNetwork => build_road_routes(plan).await?, + }; + + Ok(RoutesSnapshot { + routing_mode: match plan.routing_mode { + RoutingMode::StraightLine => "straight_line".to_string(), + RoutingMode::RoadNetwork => "road_network".to_string(), + }, + bounds, + vehicles, + }) +} + +fn build_straight_line_routes(plan: &Plan) -> Result, RoutingError> { + let mut routes = Vec::with_capacity(plan.vehicles.len()); + for vehicle in &plan.vehicles { + let metrics = metrics_for_vehicle(plan, vehicle); + let mut segments = Vec::new(); + let mut previous_coord = vehicle.depot_coord()?; + let mut previous_id = None; + for &delivery_id in &vehicle.delivery_order { + let delivery = &plan.deliveries[delivery_id]; + let coord = delivery.coord()?; + let meters = haversine_distance(previous_coord, coord).round() as i64; + let seconds = meters_to_seconds(meters); + segments.push(RouteLegGeometry { + vehicle_id: vehicle.id, + from_kind: if previous_id.is_some() { + "delivery" + } else { + "depot" + }, + from_id: previous_id, + to_kind: "delivery", + to_id: Some(delivery_id), + duration_seconds: seconds, + distance_meters: meters, + encoded_polyline: encode_polyline(&[previous_coord, coord]), + }); + previous_coord = coord; + previous_id = Some(delivery_id); + } + if let Some(last_delivery_id) = previous_id { + let depot = vehicle.depot_coord()?; + let meters = haversine_distance(previous_coord, depot).round() as i64; + let seconds = meters_to_seconds(meters); + segments.push(RouteLegGeometry { + vehicle_id: vehicle.id, + from_kind: "delivery", + from_id: Some(last_delivery_id), + to_kind: "depot", + to_id: None, + duration_seconds: seconds, + distance_meters: meters, + encoded_polyline: encode_polyline(&[previous_coord, depot]), + }); + } + routes.push(RouteLegSummary { + vehicle_id: vehicle.id, + vehicle_name: vehicle.name.clone(), + total_travel_seconds: metrics.total_travel_seconds, + total_distance_meters: metrics.total_distance_meters, + total_demand: metrics.total_demand, + total_late_seconds: metrics.total_late_seconds, + stop_count: vehicle.delivery_order.len(), + segments, + }); + } + Ok(routes) +} + +async fn build_road_routes(plan: &Plan) -> Result, RoutingError> { + let mut coords = delivery_coords(plan)?; + for vehicle in &plan.vehicles { + coords.push(vehicle.depot_coord()?); + } + + if coords.is_empty() { + return Ok(Vec::new()); + } + + let bbox = BoundingBox::from_coords(&coords).expand_for_routing(&coords); + let network = RoadNetwork::load_or_fetch(&bbox, &NetworkConfig::default(), None).await?; + + let mut routes = Vec::with_capacity(plan.vehicles.len()); + for vehicle in &plan.vehicles { + routes.push(build_vehicle_road_route(plan, &network, vehicle).await?); + } + Ok(routes) +} + +async fn build_vehicle_road_route( + plan: &Plan, + network: &RoadNetwork, + vehicle: &crate::domain::Vehicle, +) -> Result { + let metrics = metrics_for_vehicle(plan, vehicle); + let mut segments = Vec::new(); + let mut total_distance_meters = 0_i64; + let mut total_travel_seconds = 0_i64; + let mut previous_coord = vehicle.depot_coord()?; + let mut previous_id = None; + + for &delivery_id in &vehicle.delivery_order { + let delivery = &plan.deliveries[delivery_id]; + let coord = delivery.coord()?; + let route = network.route(previous_coord, coord)?; + let distance_meters = route.distance_meters.round() as i64; + total_distance_meters += distance_meters; + total_travel_seconds += route.duration_seconds; + segments.push(RouteLegGeometry { + vehicle_id: vehicle.id, + from_kind: if previous_id.is_some() { + "delivery" + } else { + "depot" + }, + from_id: previous_id, + to_kind: "delivery", + to_id: Some(delivery_id), + duration_seconds: route.duration_seconds, + distance_meters, + encoded_polyline: encode_polyline(&route_geometry_with_road_endpoints( + network, + previous_coord, + coord, + &route.geometry, + )?), + }); + previous_coord = coord; + previous_id = Some(delivery_id); + } + + if let Some(last_delivery_id) = previous_id { + let depot = vehicle.depot_coord()?; + let route = network.route(previous_coord, depot)?; + let distance_meters = route.distance_meters.round() as i64; + total_distance_meters += distance_meters; + total_travel_seconds += route.duration_seconds; + segments.push(RouteLegGeometry { + vehicle_id: vehicle.id, + from_kind: "delivery", + from_id: Some(last_delivery_id), + to_kind: "depot", + to_id: None, + duration_seconds: route.duration_seconds, + distance_meters, + encoded_polyline: encode_polyline(&route_geometry_with_road_endpoints( + network, + previous_coord, + depot, + &route.geometry, + )?), + }); + } + + Ok(RouteLegSummary { + vehicle_id: vehicle.id, + vehicle_name: vehicle.name.clone(), + total_travel_seconds, + total_distance_meters, + total_demand: metrics.total_demand, + total_late_seconds: metrics.total_late_seconds, + stop_count: vehicle.delivery_order.len(), + segments, + }) +} + +fn route_geometry_with_road_endpoints( + network: &RoadNetwork, + from: Coord, + to: Coord, + network_geometry: &[Coord], +) -> Result, RoutingError> { + let from_snap = network.snap_to_edge(from)?; + let to_snap = network.snap_to_edge(to)?; + Ok(route_geometry_with_endpoints( + from, + Some(from_snap.snapped), + to, + Some(to_snap.snapped), + network_geometry, + )) +} + +fn route_geometry_with_endpoints( + from: Coord, + from_road: Option, + to: Coord, + to_road: Option, + network_geometry: &[Coord], +) -> Vec { + let mut geometry = Vec::with_capacity(network_geometry.len() + 4); + push_unique_coord(&mut geometry, from); + if let Some(coord) = from_road { + push_unique_coord(&mut geometry, coord); + } + for &coord in network_geometry { + push_unique_coord(&mut geometry, coord); + } + if let Some(coord) = to_road { + push_unique_coord(&mut geometry, coord); + } + push_unique_coord(&mut geometry, to); + geometry +} + +fn push_unique_coord(geometry: &mut Vec, coord: Coord) { + if geometry.last().copied() != Some(coord) { + geometry.push(coord); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/domain/route_metrics/routes/tests.rs b/src/domain/route_metrics/routes/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..e733ff983926d4aea6267348b4202dbeb970103d --- /dev/null +++ b/src/domain/route_metrics/routes/tests.rs @@ -0,0 +1,64 @@ +use solverforge_maps::{Coord, RoadNetwork}; + +use super::{route_geometry_with_endpoints, route_geometry_with_road_endpoints}; + +#[test] +fn road_route_geometry_adds_edge_snapped_visual_endpoints() { + let network = RoadNetwork::from_test_data( + &[(0.0, 0.0), (0.0, 0.01), (0.01, 0.01)], + &[ + (0, 1, 60.0, 1_000.0), + (1, 2, 60.0, 1_000.0), + (1, 0, 60.0, 1_000.0), + (2, 1, 60.0, 1_000.0), + ], + ); + let from = Coord::new(0.001, 0.004); + let to = Coord::new(0.006, 0.011); + + let geometry = route_geometry_with_road_endpoints( + &network, + from, + to, + &[ + Coord::new(0.0, 0.0), + Coord::new(0.0, 0.01), + Coord::new(0.01, 0.01), + ], + ) + .expect("edge snaps should exist"); + + assert_eq!(geometry.first().copied(), Some(from)); + assert_eq!(geometry.get(1).copied(), Some(Coord::new(0.0, 0.004))); + assert_eq!( + geometry.get(geometry.len() - 2).copied(), + Some(Coord::new(0.006, 0.01)) + ); + assert_eq!(geometry.last().copied(), Some(to)); +} + +#[test] +fn road_route_geometry_is_stitched_to_exact_domain_endpoints() { + let from = Coord::new(39.0, -75.0); + let snapped_start = Coord::new(39.0002, -75.0002); + let snapped_end = Coord::new(39.0018, -75.0018); + let to = Coord::new(39.002, -75.002); + + let geometry = + route_geometry_with_endpoints(from, None, to, None, &[snapped_start, snapped_end]); + + assert_eq!(geometry.first().copied(), Some(from)); + assert_eq!(geometry.last().copied(), Some(to)); + assert_eq!(geometry.len(), 4); +} + +#[test] +fn road_route_geometry_avoids_duplicate_endpoint_points() { + let from = Coord::new(39.0, -75.0); + let mid = Coord::new(39.001, -75.001); + let to = Coord::new(39.002, -75.002); + + let geometry = route_geometry_with_endpoints(from, None, to, None, &[from, mid, to]); + + assert_eq!(geometry, vec![from, mid, to]); +} diff --git a/src/domain/route_metrics/scoring.rs b/src/domain/route_metrics/scoring.rs new file mode 100644 index 0000000000000000000000000000000000000000..5fa8075a61ffabef87d50168c2325dbdb9cfd2cc --- /dev/null +++ b/src/domain/route_metrics/scoring.rs @@ -0,0 +1,186 @@ +use std::collections::HashSet; + +use solverforge::prelude::HardSoftScore; +use crate::domain::{ + Delivery, DeliveryKind, DeliveryPreview, Plan, PlanPreview, VehiclePreview, VehiclePreviewStop, +}; + +use super::metrics::metrics_for_vehicle; +use super::types::{ + PlanScoreComponents, RouteStopMetrics, VehicleRouteMetrics, UNASSIGNED_DELIVERY_HARD_PENALTY, + UNREACHABLE_HARD_PENALTY, +}; + +pub fn preview_for_plan(plan: &Plan) -> PlanPreview { + let components = evaluate_plan(plan); + let vehicle_metrics: Vec = plan + .vehicles + .iter() + .map(|vehicle| metrics_for_vehicle(plan, vehicle)) + .collect(); + + let vehicles = plan + .vehicles + .iter() + .zip(vehicle_metrics.iter()) + .map(|(vehicle, metrics)| { + let stops = metrics + .stops + .iter() + .map(|stop| vehicle_preview_stop(plan, stop)) + .collect(); + + VehiclePreview { + vehicle_id: vehicle.id, + vehicle_name: vehicle.name.clone(), + total_demand: metrics.total_demand, + capacity_overage: metrics.capacity_overage, + stop_count: metrics.stops.len(), + total_travel_seconds: metrics.total_travel_seconds, + total_wait_seconds: metrics.total_wait_seconds, + total_service_seconds: metrics.total_service_seconds, + total_late_seconds: metrics.total_late_seconds, + start_time: metrics.start_time, + end_time: metrics.end_time, + stops, + } + }) + .collect(); + + let mut deliveries = plan + .deliveries + .iter() + .map(delivery_preview) + .collect::>(); + + for (vehicle, metrics) in plan.vehicles.iter().zip(vehicle_metrics.iter()) { + for stop in &metrics.stops { + let preview = &mut deliveries[stop.delivery_id]; + preview.assigned_vehicle_id = Some(vehicle.id); + preview.assigned_vehicle_name = Some(vehicle.name.clone()); + preview.sequence = Some(stop.sequence); + preview.arrival_time = Some(stop.arrival_time); + preview.service_start_time = Some(stop.service_start_time); + preview.departure_time = Some(stop.departure_time); + preview.late_seconds = Some(stop.late_seconds); + } + } + + PlanPreview { + hard_score: components.hard_score(), + soft_score: components.soft_score(), + unassigned_delivery_ids: components.unassigned_delivery_ids(plan), + vehicles, + deliveries, + } +} + +/// Mirrors the constraint model for UI previews and insertion recommendations. +/// +/// SolverForge constraints remain the authoritative score during solving. This +/// helper gives non-solver UI flows a cheap, explainable score breakdown from +/// the same route shadows and assignment coverage concepts. +pub fn evaluate_plan(plan: &Plan) -> PlanScoreComponents { + let assigned: HashSet = plan + .vehicles + .iter() + .flat_map(|vehicle| vehicle.delivery_order.iter().copied()) + .collect(); + + let mut components = PlanScoreComponents { + unassigned_count: plan + .deliveries + .iter() + .filter(|delivery| !assigned.contains(&delivery.id)) + .count(), + ..PlanScoreComponents::default() + }; + + for vehicle in &plan.vehicles { + let metrics = metrics_for_vehicle(plan, vehicle); + components.capacity_overage += i64::from(metrics.capacity_overage); + components.late_seconds += metrics.total_late_seconds; + components.unreachable_legs += metrics.unreachable_legs; + components.travel_seconds += metrics.total_travel_seconds; + } + + components +} + +impl PlanScoreComponents { + pub fn hard_score(&self) -> i64 { + -((self.unassigned_count as i64 * UNASSIGNED_DELIVERY_HARD_PENALTY) + + self.capacity_overage + + self.late_seconds + + (self.unreachable_legs as i64 * UNREACHABLE_HARD_PENALTY)) + } + + pub fn soft_score(&self) -> i64 { + -self.travel_seconds + } + + pub fn score(&self) -> HardSoftScore { + HardSoftScore::of(self.hard_score(), self.soft_score()) + } + + pub fn unassigned_delivery_ids(&self, plan: &Plan) -> Vec { + let assigned: HashSet = plan + .vehicles + .iter() + .flat_map(|vehicle| vehicle.delivery_order.iter().copied()) + .collect(); + plan.deliveries + .iter() + .filter(|delivery| !assigned.contains(&delivery.id)) + .map(|delivery| delivery.id) + .collect() + } +} + +fn vehicle_preview_stop(plan: &Plan, stop: &RouteStopMetrics) -> VehiclePreviewStop { + let delivery = &plan.deliveries[stop.delivery_id]; + VehiclePreviewStop { + delivery_id: delivery.id, + label: delivery.label.clone(), + kind: kind_name(delivery), + sequence: stop.sequence, + demand: delivery.demand, + min_start_time: delivery.min_start_time, + max_end_time: delivery.max_end_time, + arrival_time: stop.arrival_time, + service_start_time: stop.service_start_time, + departure_time: stop.departure_time, + travel_seconds_from_previous: stop.travel_seconds_from_previous, + wait_seconds: stop.wait_seconds, + late_seconds: stop.late_seconds, + } +} + +fn delivery_preview(delivery: &Delivery) -> DeliveryPreview { + DeliveryPreview { + delivery_id: delivery.id, + label: delivery.label.clone(), + kind: kind_name(delivery), + demand: delivery.demand, + min_start_time: delivery.min_start_time, + max_end_time: delivery.max_end_time, + service_duration: delivery.service_duration, + assigned_vehicle_id: None, + assigned_vehicle_name: None, + sequence: None, + arrival_time: None, + service_start_time: None, + departure_time: None, + late_seconds: None, + } +} + +fn kind_name(delivery: &Delivery) -> String { + match delivery.kind { + DeliveryKind::Residential => "residential", + DeliveryKind::Business => "business", + DeliveryKind::Restaurant => "restaurant", + DeliveryKind::Other => "other", + } + .to_string() +} diff --git a/src/domain/route_metrics/tests.rs b/src/domain/route_metrics/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..a7394a0f8687169b6ff316c73950bf6d3f4c9541 --- /dev/null +++ b/src/domain/route_metrics/tests.rs @@ -0,0 +1,92 @@ +use super::*; +use crate::domain::{Delivery, DeliveryKind, Plan, RoutingMode, Vehicle}; + +fn sample_plan() -> Plan { + let deliveries = vec![ + Delivery::new( + 0, + "A", + DeliveryKind::Business, + (43.7696, 11.2558), + 3, + (8 * 3600, 12 * 3600), + 10 * 60, + ), + Delivery::new( + 1, + "B", + DeliveryKind::Residential, + (43.7710, 11.2620), + 2, + (9 * 3600, 18 * 3600), + 5 * 60, + ), + Delivery::new( + 2, + "C", + DeliveryKind::Restaurant, + (43.7755, 11.2540), + 4, + (6 * 3600, 10 * 3600), + 15 * 60, + ), + ]; + let mut vehicles = vec![ + Vehicle::new(0, "Alpha", 10, 43.7696, 11.2558, 6 * 3600), + Vehicle::new(1, "Bravo", 8, 43.7745, 11.2487, 6 * 3600), + ]; + vehicles[0].delivery_order = vec![0, 1]; + vehicles[1].delivery_order = vec![2]; + Plan::new("Sample", deliveries, vehicles) +} + +#[tokio::test] +async fn prepare_plan_populates_vehicle_routing_data() { + let mut plan = sample_plan(); + plan.routing_mode = RoutingMode::StraightLine; + prepare_plan(&mut plan) + .await + .expect("straight-line prep should succeed"); + assert!(plan + .vehicles + .iter() + .all(|vehicle| vehicle.prepared_routing.is_some())); +} + +#[test] +fn preview_reports_assignments() { + let plan = sample_plan(); + let preview = preview_for_plan(&plan); + assert_eq!(preview.unassigned_delivery_ids.len(), 0); + assert_eq!(preview.vehicles.len(), 2); + assert_eq!(preview.deliveries[0].assigned_vehicle_id, Some(0)); +} + +#[test] +fn unassigned_deliveries_are_dominant_hard_penalties() { + let mut plan = sample_plan(); + plan.vehicles[0].delivery_order.clear(); + plan.vehicles[1].delivery_order.clear(); + + let components = evaluate_plan(&plan); + + assert_eq!(components.unassigned_count, 3); + assert!( + components.hard_score() <= -(3 * UNASSIGNED_DELIVERY_HARD_PENALTY), + "unassigned deliveries must not be cheaper than seconds-based route violations" + ); +} + +#[tokio::test] +async fn empty_road_network_routes_have_no_bounds_or_vehicles() { + let mut plan = Plan::new("Empty", Vec::new(), Vec::new()); + plan.routing_mode = RoutingMode::RoadNetwork; + + let snapshot = build_routes_snapshot(&plan) + .await + .expect("empty road-network routes should not require a bounding box"); + + assert_eq!(snapshot.routing_mode, "road_network"); + assert!(snapshot.bounds.is_none()); + assert!(snapshot.vehicles.is_empty()); +} diff --git a/src/domain/route_metrics/types.rs b/src/domain/route_metrics/types.rs new file mode 100644 index 0000000000000000000000000000000000000000..f954b2a020ff1cdcbce16de6bd8851ed0c6e1d3e --- /dev/null +++ b/src/domain/route_metrics/types.rs @@ -0,0 +1,121 @@ +use serde::Serialize; +use crate::domain::Plan; + +pub const UNASSIGNED_DELIVERY_HARD_PENALTY: i64 = 1_000_000; +pub(super) const UNREACHABLE_HARD_PENALTY: i64 = 86_400; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct PreparedVehicleRouting { + pub problem_data_index: usize, + pub capacity: i64, + pub demands: Vec, + pub distance_matrix: Vec>, + pub time_windows: Vec<(i64, i64)>, + pub service_durations: Vec, + pub travel_times: Vec>, + pub vehicle_departure_time: i64, + pub depot_to_delivery_seconds: Vec, + pub delivery_to_depot_seconds: Vec, + pub depot_to_delivery_meters: Vec, + pub delivery_to_depot_meters: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct VehicleRouteMetrics { + pub vehicle_id: usize, + pub total_demand: i32, + pub capacity_overage: i32, + pub total_travel_seconds: i64, + pub total_wait_seconds: i64, + pub total_service_seconds: i64, + pub total_late_seconds: i64, + pub unreachable_legs: usize, + pub total_distance_meters: i64, + pub start_time: i64, + pub end_time: i64, + pub stops: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct RouteStopMetrics { + pub delivery_id: usize, + pub sequence: usize, + pub arrival_time: i64, + pub service_start_time: i64, + pub departure_time: i64, + pub travel_seconds_from_previous: i64, + pub wait_seconds: i64, + pub late_seconds: i64, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RoutesSnapshot { + pub routing_mode: String, + pub bounds: Option, + pub vehicles: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteBounds { + pub south_west: [f64; 2], + pub north_east: [f64; 2], +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteLegSummary { + pub vehicle_id: usize, + pub vehicle_name: String, + pub total_travel_seconds: i64, + pub total_distance_meters: i64, + pub total_demand: i32, + pub total_late_seconds: i64, + pub stop_count: usize, + pub segments: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteLegGeometry { + pub vehicle_id: usize, + pub from_kind: &'static str, + pub from_id: Option, + pub to_kind: &'static str, + pub to_id: Option, + pub duration_seconds: i64, + pub distance_meters: i64, + pub encoded_polyline: String, +} + +#[derive(Clone, Debug, Default)] +pub struct PlanScoreComponents { + pub unassigned_count: usize, + pub capacity_overage: i64, + pub late_seconds: i64, + pub unreachable_legs: usize, + pub travel_seconds: i64, +} + +#[derive(Clone, Debug)] +pub struct DeliveryInsertionCandidate { + pub vehicle_id: usize, + pub vehicle_name: String, + pub insert_index: usize, + pub hard_score: i64, + pub soft_score: i64, + pub delta_hard: i64, + pub delta_soft: i64, + pub preview_plan: Plan, +} + +pub trait DeliveryRoutingSolution: solverforge::cvrp::VrpSolution { + fn delivery_plan(&self) -> &Plan; +} + +impl DeliveryRoutingSolution for Plan { + fn delivery_plan(&self) -> &Plan { + self + } +} diff --git a/src/domain/vehicle.rs b/src/domain/vehicle.rs new file mode 100644 index 0000000000000000000000000000000000000000..7d4372ccb068303307595999c06be241254c27c5 --- /dev/null +++ b/src/domain/vehicle.rs @@ -0,0 +1,207 @@ +//! Vehicle planning entities and route shadow values. +//! +//! SolverForge mutates `delivery_order`. The other route fields are derived +//! shadows so constraints can read route totals without recomputing every leg. + +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; +use solverforge_maps::{Coord, RoutingError}; + +use super::{CoordValue, PreparedVehicleRouting}; + +/// A vehicle with its own depot, capacity, and departure time. +#[planning_entity] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Vehicle { + #[planning_id] + pub id: usize, + pub name: String, + pub capacity: i32, + pub home_lat: CoordValue, + pub home_lng: CoordValue, + pub departure_time: i64, + // @solverforge:begin entity-variables + /// List planning variable: ordered delivery ids assigned to this vehicle. + /// + /// The CVRP hook attributes tell SolverForge how to construct and improve + /// these lists using route distances, loads, capacities, and k-opt checks. + #[planning_list_variable( + element_collection = "deliveries", + solution_trait = "crate::domain::DeliveryRoutingSolution", + distance_meter = "solverforge::cvrp::MatrixDistanceMeter", + intra_distance_meter = "solverforge::cvrp::MatrixIntraDistanceMeter", + cw_depot_fn = "crate::domain::delivery_clarke_wright_depot", + cw_distance_fn = "crate::domain::delivery_route_distance", + cw_element_load_fn = "crate::domain::delivery_element_load", + cw_capacity_fn = "crate::domain::delivery_route_capacity", + cw_assign_route_fn = "crate::domain::replace_delivery_route", + k_opt_get_route = "crate::domain::get_delivery_route", + k_opt_set_route = "crate::domain::replace_delivery_route", + k_opt_depot_fn = "crate::domain::delivery_k_opt_depot", + k_opt_distance_fn = "crate::domain::delivery_route_distance", + k_opt_feasible_fn = "crate::domain::delivery_k_opt_feasible" + )] + pub delivery_order: Vec, + // @solverforge:end entity-variables + /// Transient per-vehicle routing data built before solving or previewing. + #[serde(skip, default)] + pub prepared_routing: Option, + #[cascading_update_shadow_variable] + #[serde(skip, default)] + pub route_total_demand: i64, + #[cascading_update_shadow_variable] + #[serde(skip, default)] + pub route_capacity_overage: i64, + #[cascading_update_shadow_variable] + #[serde(skip, default)] + pub route_total_travel_seconds: i64, + #[cascading_update_shadow_variable] + #[serde(skip, default)] + pub route_time_window_violation_seconds: i64, + #[cascading_update_shadow_variable] + #[serde(skip, default)] + pub route_unreachable_legs: usize, +} + +impl Vehicle { + /// Creates an empty route anchored at one depot. + pub fn new( + id: usize, + name: impl Into, + capacity: i32, + home_lat: f64, + home_lng: f64, + departure_time: i64, + ) -> Self { + Self { + id, + name: name.into(), + capacity, + home_lat: home_lat.into(), + home_lng: home_lng.into(), + departure_time, + // @solverforge:begin entity-variable-init + delivery_order: Vec::new(), + // @solverforge:end entity-variable-init + prepared_routing: None, + route_total_demand: 0, + route_capacity_overage: 0, + route_total_travel_seconds: 0, + route_time_window_violation_seconds: 0, + route_unreachable_legs: 0, + } + } + + /// Converts the serialized depot coordinate into the map library type. + pub fn depot_coord(&self) -> Result { + Ok(Coord::try_new(self.home_lat.get(), self.home_lng.get())?) + } + + /// Recomputes the derived route totals read by constraints and previews. + pub fn refresh_route_shadows(&mut self) { + let Some(prepared) = self.prepared_routing.as_ref() else { + self.clear_route_shadows(); + return; + }; + + let mut total_demand = 0_i64; + let mut total_travel_seconds = 0_i64; + let mut time_window_violation_seconds = 0_i64; + let mut unreachable_legs = 0_usize; + let mut current = prepared.vehicle_departure_time; + let mut previous: Option = None; + + for &delivery_id in &self.delivery_order { + total_demand += i64::from(prepared.demands[delivery_id]); + let travel = match previous { + Some(previous_id) => prepared.travel_times[previous_id][delivery_id], + None => prepared.depot_to_delivery_seconds[delivery_id], + }; + let normalized_travel = normalize_travel_time(travel); + total_travel_seconds += normalized_travel; + current += normalized_travel; + + let (min_start, max_end) = prepared.time_windows[delivery_id]; + if current < min_start { + current = min_start; + } + current += prepared.service_durations[delivery_id]; + if current > max_end { + time_window_violation_seconds += current - max_end; + } + if travel == i64::MAX { + // Keep unreachable legs visible in both hard scoring and the + // time-window summary instead of silently treating them as zero. + unreachable_legs += 1; + time_window_violation_seconds += 86_400; + } + previous = Some(delivery_id); + } + + if let Some(last_delivery_id) = previous { + let return_travel = prepared.delivery_to_depot_seconds[last_delivery_id]; + total_travel_seconds += normalize_travel_time(return_travel); + if return_travel == i64::MAX { + unreachable_legs += 1; + time_window_violation_seconds += 86_400; + } + } + + self.route_total_demand = total_demand; + self.route_capacity_overage = (total_demand - prepared.capacity).max(0); + self.route_total_travel_seconds = total_travel_seconds; + self.route_time_window_violation_seconds = time_window_violation_seconds; + self.route_unreachable_legs = unreachable_legs; + } + + /// Constraint helper for the capacity rule. + pub fn total_assigned_demand(&self) -> i64 { + self.route_total_demand + } + + /// Constraint helper for the capacity rule. + pub fn capacity_overage(&self) -> i64 { + self.route_capacity_overage + } + + /// Constraint helper for the soft travel-time rule. + pub fn total_travel_seconds(&self) -> i64 { + self.route_total_travel_seconds + } + + /// Constraint helper for the time-window rule. + pub fn time_window_violation_seconds(&self) -> i64 { + self.route_time_window_violation_seconds + } + + fn clear_route_shadows(&mut self) { + self.route_total_demand = 0; + self.route_capacity_overage = 0; + self.route_total_travel_seconds = 0; + self.route_time_window_violation_seconds = 0; + self.route_unreachable_legs = 0; + } +} + +/// Converts an unreachable sentinel into the same one-day penalty used in UI previews. +fn normalize_travel_time(value: i64) -> i64 { + if value == i64::MAX { + 86_400 + } else { + value.max(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vehicle_construction() { + let entity = Vehicle::new(7, "Van 7", 28, 39.95, -75.16, 6 * 3600); + assert_eq!(entity.id, 7); + assert_eq!(entity.name, "Van 7"); + assert_eq!(entity.capacity, 28); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..4adc36ecbc3646cfc1f415f54778c0b5f993515c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +//! SolverForge deliveries tutorial application. +//! +//! The crate is split the same way a generated SolverForge app is split: +//! domain types define the planning model, constraints define scoring, data +//! builds deterministic examples, `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..f50358af864c5c151310439a7e4da18ef2e43727 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,46 @@ +//! Axum entrypoint for the deliveries tutorial. +//! +//! Run with `make run-release`, then open the printed local URL. The same +//! binary is used by the Docker Space image, where `PORT` is provided by the +//! platform. + +use solverforge_deliveries::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 is visible in + // local runs and in 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); + + // The app serves three surfaces from one process: local API routes, shared + // solverforge-ui assets, and this repo's static browser modules. + 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-deliveries 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).await.unwrap(); +} diff --git a/src/solver/mod.rs b/src/solver/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..ec20f5b5dccf26049c9c8e2d014e24abdc7cc4b3 --- /dev/null +++ b/src/solver/mod.rs @@ -0,0 +1,9 @@ +//! Solver-runtime facade exports for the deliveries app. +//! +//! `service.rs` hides the retained `SolverManager` details so the HTTP +//! layer only needs a small application-specific API. + +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..3dacd98fe5a90ce8e071e16b5880b4ad43666440 --- /dev/null +++ b/src/solver/service.rs @@ -0,0 +1,200 @@ +//! Retained-job orchestration for delivery solves. +//! +//! SolverForge owns search and scoring. This service owns app-level concerns: +//! turning public string ids into runtime job ids, storing SSE broadcasters, +//! and exposing pause/resume/cancel/delete operations to the HTTP 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 crate::domain::Plan; + +mod runtime_payload; + +use runtime_payload::{bootstrap_event_type, event_payload, status_event_payload}; + +// 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, + last_event: String, +} + +/// Manages retained solving jobs and broadcasts lifecycle-complete SSE payloads. +pub struct SolverService { + jobs: Arc>>, +} + +impl SolverService { + 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: Plan) -> Result { + let (job_id, receiver) = MANAGER.solve(plan)?; + let status = MANAGER.get_status(job_id)?; + let initial_event = status_event_payload( + job_id, + bootstrap_event_type(status.lifecycle_state), + &status, + ); + let (sse_tx, _) = broadcast::channel(64); + + self.jobs.write().insert( + job_id, + JobState { + sse_tx: sse_tx.clone(), + last_event: initial_event, + }, + ); + + 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()) + } + + /// Returns the last event retained for a late SSE subscriber. + pub fn sse_snapshot(&self, id: &str) -> Option { + let job_id = parse_job_id(id).ok()?; + self.jobs + .read() + .get(&job_id) + .map(|state| state.last_event.clone()) + } + + /// Builds the first SSE payload a client should receive after connecting. + pub fn bootstrap_event(&self, id: &str) -> Result { + if let Some(snapshot) = self.sse_snapshot(id) { + return Ok(snapshot); + } + + let job_id = parse_job_id(id)?; + let status = MANAGER.get_status(job_id)?; + 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())) + } + }; + + let mut jobs = jobs.write(); + if let Some(state) = jobs.get_mut(&job_id) { + state.last_event = payload.clone(); + } else { + return; + } + drop(jobs); + + 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/src/solver/service/runtime_payload.rs b/src/solver/service/runtime_payload.rs new file mode 100644 index 0000000000000000000000000000000000000000..a6a83fc7cd2e600bbdd0dabe051632433d6ced75 --- /dev/null +++ b/src/solver/service/runtime_payload.rs @@ -0,0 +1,169 @@ +//! JSON event payloads sent over the deliveries SSE stream. +//! +//! SolverForge emits strongly typed lifecycle events. This module converts them +//! to the stable camelCase JSON shape consumed by the browser status bar, +//! snapshot renderer, and route viewer. + +use serde::Serialize; +use solverforge::{ + HardSoftScore, SolverEventMetadata, SolverLifecycleState, SolverStatus, SolverTelemetry, + SolverTerminalReason, +}; +use std::time::Duration; + +use crate::api::PlanDto; +use crate::domain::Plan; + +#[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 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 event_payload( + job_id: usize, + event_type: &'static str, + metadata: &SolverEventMetadata, + solution: Option<&Plan>, + error: Option<&str>, +) -> String { + serialize_payload(JobEventPayload { + id: job_id.to_string(), + job_id: job_id.to_string(), + event_type, + event_sequence: metadata.event_sequence, + lifecycle_state: lifecycle_state_label(metadata.lifecycle_state), + terminal_reason: metadata.terminal_reason.map(terminal_reason_label), + telemetry: telemetry_payload(&metadata.telemetry), + current_score: metadata.current_score.map(|score| score.to_string()), + best_score: metadata.best_score.map(|score| score.to_string()), + snapshot_revision: metadata.snapshot_revision, + solution: solution.map(PlanDto::from_plan), + error: error.map(ToOwned::to_owned), + }) +} + +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/static/app.css b/static/app.css new file mode 100644 index 0000000000000000000000000000000000000000..59072189a5aaf0c91f1866deb9178665d1160da7 --- /dev/null +++ b/static/app.css @@ -0,0 +1,196 @@ +.deliveries-shell { + display: grid; + gap: 1.25rem; +} + +.deliveries-grid { + align-items: stretch; + display: grid; + gap: 1rem; + grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); + min-height: max(680px, calc(100vh - 250px)); +} + +.deliveries-stack { + display: grid; + gap: 1rem; +} + +.deliveries-card { + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(16, 185, 129, 0.14); + border-radius: 1rem; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08); + padding: 1rem 1rem 1.1rem; +} + +.deliveries-card h3, +.deliveries-card h4 { + margin: 0 0 0.5rem; +} + +.deliveries-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: end; +} + +.deliveries-field { + display: grid; + gap: 0.25rem; + min-width: 140px; +} + +.deliveries-field label { + color: var(--sf-gray-700); + font-size: 0.875rem; + font-weight: 600; +} + +.deliveries-field input, +.deliveries-field select { + border: 1px solid rgba(15, 23, 42, 0.14); + border-radius: 0.75rem; + min-height: 2.75rem; + padding: 0 0.75rem; +} + +.deliveries-kpis { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} + +.deliveries-kpi { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(255, 255, 255, 0.92)); + border-radius: 0.85rem; + padding: 0.85rem 0.95rem; +} + +.deliveries-kpi__label { + color: var(--sf-gray-600); + font-size: 0.8rem; + margin-bottom: 0.2rem; +} + +.deliveries-kpi__value { + font-size: 1.3rem; + font-weight: 700; +} + +.deliveries-map { + border-radius: 1rem; + flex: 1; + min-height: max(620px, calc(100vh - 320px)); + overflow: hidden; +} + +.deliveries-map-card { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.deliveries-list { + display: grid; + gap: 0.75rem; +} + +.deliveries-list__row { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 0.85rem; + cursor: pointer; + display: grid; + gap: 0.5rem; + padding: 0.85rem 0.95rem; +} + +.deliveries-list__row.is-focused { + border-color: rgba(16, 185, 129, 0.55); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12); +} + +.deliveries-list__row:focus-visible { + outline: 3px solid rgba(16, 185, 129, 0.35); + outline-offset: 2px; +} + +.deliveries-list__top, +.deliveries-list__meta, +.deliveries-list__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + justify-content: space-between; +} + +.deliveries-list__meta { + color: var(--sf-gray-600); + font-size: 0.875rem; + justify-content: flex-start; +} + +.deliveries-tag { + background: rgba(15, 23, 42, 0.06); + border-radius: 999px; + display: inline-flex; + font-size: 0.78rem; + font-weight: 600; + gap: 0.35rem; + padding: 0.2rem 0.55rem; +} + +.deliveries-panel { + display: none; +} + +.deliveries-panel.is-active { + display: block; +} + +.deliveries-table { + width: 100%; + border-collapse: collapse; +} + +.deliveries-table th, +.deliveries-table td { + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + padding: 0.6rem 0.35rem; + text-align: left; + vertical-align: top; +} + +.deliveries-table th { + color: var(--sf-gray-600); + font-size: 0.82rem; + text-transform: uppercase; +} + +.deliveries-actions-inline { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.deliveries-modal-list { + display: grid; + gap: 0.75rem; +} + +.deliveries-empty { + color: var(--sf-gray-600); + padding: 1rem 0; +} + +@media (max-width: 980px) { + .deliveries-grid { + grid-template-columns: 1fr; + } + + .deliveries-map { + min-height: 440px; + } +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000000000000000000000000000000000000..c1aff4dac93c1221d35658d0b6740b99e5a51b4b --- /dev/null +++ b/static/app.js @@ -0,0 +1,3 @@ +import { boot } from './app/main.mjs'; + +boot(); diff --git a/static/app/main.mjs b/static/app/main.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c1e6a4d9491ab204fe2d574a675484bf24754857 --- /dev/null +++ b/static/app/main.mjs @@ -0,0 +1,287 @@ +import { + clonePlan, + refreshPlan, + refreshServerPlan, +} from './models.mjs'; +import { fetchJson, showError } from './ui/components.mjs'; +import { renderData } from './ui/data-tables.mjs'; +import { createLayout } from './ui/layout.mjs'; +import { sameRouteIdentity, syncLifecycleDataset } from './ui/lifecycle.mjs'; +import { buildAnalysisBody, buildRecommendationBody } from './ui/modals.mjs'; +import { renderMap, renderRouteList, renderSummary, renderTimelines } from './ui/overview.mjs'; + +const DEFAULT_DEMO = 'PHILADELPHIA'; + +export async function boot() { + const config = await fetchJson('/sf-config.json'); + const backend = SF.createBackend({ baseUrl: '' }); + const app = document.getElementById('sf-app'); + + let currentPlan = null; + let currentRoutes = null; + let currentDemo = DEFAULT_DEMO; + let mapCtrl = null; + let focusedVehicleId = null; + let routeRequestToken = 0; + // Route geometry is only trustworthy for one retained job, snapshot revision, + // and routing mode. This identity prevents stale map lines from surviving + // dataset switches, routing-mode changes, or newer solver snapshots. + let activeRouteIdentity = null; + let activeTab = 'overview'; + let mapLocationSignature = null; + + const statusBar = SF.createStatusBar({ + constraints: [ + 'all_deliveries_assigned', + 'vehicle_capacity', + 'delivery_time_windows', + 'total_travel_time', + ], + }); + + const layout = createLayout({ + app, + config, + statusBar, + actions: { + onSolve: () => loadAndSolve(), + onPause: () => solver.pause().catch(showError), + onResume: () => solver.resume().catch(showError), + onCancel: () => solver.cancel().catch(showError), + onAnalyze: () => openAnalysis(), + }, + onTabChange: (tabId) => setActiveTab(tabId), + }); + + const solver = SF.createSolver({ + backend, + statusBar, + onProgress: syncLifecycle, + onPauseRequested: syncLifecycle, + onSolution: async (snapshot, meta) => handleSnapshotEvent(snapshot, meta), + onPaused: async (snapshot, meta) => handleSnapshotEvent(snapshot, meta), + onResumed: syncLifecycle, + onCancelled: async (snapshot, meta) => handleSnapshotEvent(snapshot, meta), + onComplete: async (snapshot, meta) => handleSnapshotEvent(snapshot, meta), + onFailure: async (_message, meta, snapshot, analysis) => { + await handleSnapshotEvent(snapshot, meta); + if (analysis) layout.analysisModal.setBody(buildAnalysisBody(analysis)); + }, + onAnalysis: (analysis) => layout.analysisModal.setBody(buildAnalysisBody(analysis)), + onError: showError, + }); + + layout.reloadButton.addEventListener('click', async () => loadDemoData(layout.demoField.select.value)); + layout.demoField.select.addEventListener('change', async () => loadDemoData(layout.demoField.select.value)); + layout.routingField.select.addEventListener('change', () => { + if (!currentPlan) return; + invalidateRoutes(); + currentPlan.routingMode = layout.routingField.select.value; + renderDraftPlan(currentPlan); + }); + + setActiveTab(activeTab); + await loadDemoData(DEFAULT_DEMO); + + function setActiveTab(tabId) { + activeTab = tabId; + Object.entries(layout.panels).forEach(([id, panel]) => { + panel.classList.toggle('is-active', id === tabId); + }); + } + + async function loadDemoData(demoId) { + currentDemo = demoId; + invalidateRoutes(); + if (currentPlan) { + renderMapView(); + renderRouteListView(); + } + const plan = await fetchJson(`/demo-data/${demoId}`); + plan.routingMode = plan.routingMode || layout.routingField.select.value; + renderServerPlan(plan); + } + + async function loadAndSolve() { + if (!currentPlan) return; + invalidateRoutes(); + renderDraftPlan({ ...currentPlan, routingMode: layout.routingField.select.value }); + await cleanupTerminalJob(); + await solver.start(clonePlan(currentPlan)); + syncLifecycle(); + } + + async function cleanupTerminalJob() { + const state = solver.getLifecycleState(); + if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) return; + try { + await solver.delete(); + } catch (_error) { + // Retained terminal cleanup is opportunistic before starting a new job. + } + } + + async function handleSnapshotEvent(snapshot, meta) { + if (snapshot?.solution) { + renderServerPlan(snapshot.solution, meta); + } + await loadRoutes(meta); + syncLifecycle(meta); + } + + async function loadRoutes(meta) { + const requested = routeIdentityFrom(meta); + if (!requested || !activeRouteIdentity || !sameRouteIdentity(requested, activeRouteIdentity)) return; + const token = ++routeRequestToken; + try { + const routes = await fetchJson(`/jobs/${requested.jobId}/routes?snapshot_revision=${requested.snapshotRevision}`); + const routingMode = routes.routingMode || requested.routingMode; + if (!routeResponseStillCurrent(token, requested, routingMode)) return; + currentRoutes = { ...routes, jobId: requested.jobId, snapshotRevision: requested.snapshotRevision, routingMode }; + renderMapView(); + renderRouteListView(); + } catch (_error) { + if (token === routeRequestToken && activeRouteIdentity && sameRouteIdentity(requested, activeRouteIdentity)) { + currentRoutes = null; + renderMapView(); + renderRouteListView(); + } + } + } + + function routeResponseStillCurrent(token, requested, routingMode) { + return ( + token === routeRequestToken && + activeRouteIdentity && + sameRouteIdentity(requested, activeRouteIdentity) && + currentPlan?.routingMode === requested.routingMode && + routingMode === requested.routingMode + ); + } + + async function openAnalysis() { + if (!solver.getJobId()) return; + const analysis = await solver.analyzeSnapshot(); + layout.analysisModal.setBody(buildAnalysisBody(analysis)); + layout.analysisModal.open(); + } + + async function openRecommendations(deliveryId) { + if (!currentPlan) return; + const response = await fetch('/recommendations/delivery-insertions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plan: currentPlan, deliveryId, limit: 8 }), + }); + if (!response.ok) throw new Error('Failed to load recommendations'); + const payload = await response.json(); + layout.recommendationModal.setBody(buildRecommendationBody(payload, (previewPlan) => { + renderServerPlan(previewPlan); + layout.recommendationModal.close(); + })); + layout.recommendationModal.open(); + } + + function renderDraftPlan(plan) { + renderPlan(plan, refreshPlan); + } + + function renderServerPlan(plan, meta = null) { + invalidateRoutes(); + renderPlan(plan, refreshServerPlan); + // A solver snapshot is the only source that can authorize `/routes` reads. + activeRouteIdentity = meta ? routeIdentityFrom(meta) : null; + } + + function renderPlan(plan, refresh) { + const nextMapLocationSignature = locationSignature(plan); + const shouldFitMap = nextMapLocationSignature !== mapLocationSignature; + currentPlan = refresh({ ...plan, routingMode: plan.routingMode || layout.routingField.select.value }); + mapLocationSignature = nextMapLocationSignature; + layout.routingField.select.value = currentPlan.routingMode || 'road_network'; + renderSummaryView(); + renderRouteListView(); + renderMapView({ fitBounds: shouldFitMap }); + renderTimelinesView(); + renderDataView(); + } + + function invalidateRoutes() { + routeRequestToken += 1; + currentRoutes = null; + focusedVehicleId = null; + // Route tables and map geometry must be reloaded for the next snapshot. + activeRouteIdentity = null; + } + + function routeIdentityFrom(meta) { + const jobId = meta?.jobId != null ? String(meta.jobId) : solver.getJobId(); + const snapshotRevision = + meta?.snapshotRevision != null ? meta.snapshotRevision : solver.getSnapshotRevision(); + const routingMode = currentPlan?.routingMode || null; + if (!jobId || snapshotRevision == null || !routingMode) return null; + return { jobId: String(jobId), snapshotRevision: String(snapshotRevision), routingMode }; + } + + function renderSummaryView() { + renderSummary({ currentDemo, currentPlan, summaryCard: layout.summaryCard, summaryMetrics: layout.summaryMetrics }); + } + + function renderRouteListView() { + renderRouteList({ + currentPlan, + focusedVehicleId, + routeList: layout.routeList, + onFocusVehicle: (vehicleId) => { + focusedVehicleId = focusedVehicleId === vehicleId ? null : vehicleId; + renderMapView(); + renderRouteListView(); + }, + }); + } + + function renderMapView({ fitBounds = false } = {}) { + renderMap({ + currentPlan, + currentRoutes, + focusedVehicleId, + fitBounds, + mapCtrl, + setMapCtrl: (next) => { + mapCtrl = next; + }, + }); + } + + function renderTimelinesView() { + renderTimelines({ + currentPlan, + vehicleTimeline: layout.vehicleTimeline, + deliveryTimeline: layout.deliveryTimeline, + }); + } + + function renderDataView() { + renderData({ + currentPlan, + dataBody: layout.dataBody, + onRecommend: async (deliveryId) => { + try { + await openRecommendations(deliveryId); + } catch (error) { + showError(error); + } + }, + }); + } + + function syncLifecycle(meta) { + syncLifecycleDataset(app, solver, meta); + } + + function locationSignature(plan) { + const deliveries = (plan.deliveries || []).map((delivery) => `${delivery.id}:${delivery.lat}:${delivery.lng}`); + const vehicles = (plan.vehicles || []).map((vehicle) => `${vehicle.id}:${vehicle.homeLat}:${vehicle.homeLng}`); + return `${deliveries.join('|')}::${vehicles.join('|')}`; + } +} diff --git a/static/app/models.mjs b/static/app/models.mjs new file mode 100644 index 0000000000000000000000000000000000000000..f9471cc4a6391b63eb59abc7d94020e680b429d8 --- /dev/null +++ b/static/app/models.mjs @@ -0,0 +1,8 @@ +export { + clonePlan, + normalizePlan, + refreshPlan, + refreshServerPlan, +} from './models/core.mjs'; +export { formatClock, formatDuration, iconForKind, kindLabel, toneForKind } from './models/formatters.mjs'; +export { buildDeliveryTimelineModel, buildVehicleTimelineModel } from './models/timeline.mjs'; diff --git a/static/app/models/core.mjs b/static/app/models/core.mjs new file mode 100644 index 0000000000000000000000000000000000000000..1c886dc595809f670bf05721d8ceb81c92c66f2c --- /dev/null +++ b/static/app/models/core.mjs @@ -0,0 +1,57 @@ +import { buildPreview } from './preview.mjs'; + +export function clonePlan(plan) { + return JSON.parse(JSON.stringify(plan)); +} + +export function refreshPlan(plan) { + const normalized = normalizePlan(clonePlan(plan)); + normalized.viewState = normalized.viewState || {}; + normalized.viewState.preview = buildPreview(normalized); + return normalized; +} + +export function refreshServerPlan(plan) { + const normalized = normalizePlan(clonePlan(plan)); + normalized.viewState = normalized.viewState || {}; + if (!normalized.viewState.preview) { + normalized.viewState.preview = buildPreview(normalized); + } + return normalized; +} + +export function normalizePlan(plan) { + const normalized = clonePlan(plan); + normalized.deliveries = normalized.deliveries || []; + normalized.vehicles = normalized.vehicles || []; + normalized.viewState = normalized.viewState || {}; + + const oldToNew = new Map(); + normalized.deliveries = normalized.deliveries.map((delivery, index) => { + oldToNew.set(delivery.id, index); + return { + kind: 'other', + demand: 1, + minStartTime: 0, + maxEndTime: 24 * 3600, + serviceDuration: 0, + ...delivery, + id: index, + }; + }); + + normalized.vehicles = normalized.vehicles.map((vehicle, index) => ({ + ...vehicle, + name: vehicle.name || `Vehicle ${index + 1}`, + capacity: Number(vehicle.capacity || 0), + homeLat: Number(vehicle.homeLat || 0), + homeLng: Number(vehicle.homeLng || 0), + departureTime: Number(vehicle.departureTime || 0), + deliveryOrder: (vehicle.deliveryOrder || []) + .map((oldId) => oldToNew.get(oldId)) + .filter((value) => value !== undefined), + id: index, + })); + + return normalized; +} diff --git a/static/app/models/formatters.mjs b/static/app/models/formatters.mjs new file mode 100644 index 0000000000000000000000000000000000000000..45051473d3ffd015cb99a9f602baa951b9e88122 --- /dev/null +++ b/static/app/models/formatters.mjs @@ -0,0 +1,53 @@ +export function formatClock(seconds) { + const safe = Math.max(0, Math.floor(seconds || 0)); + const hours = Math.floor(safe / 3600) % 24; + const minutes = Math.floor((safe % 3600) / 60); + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +} + +export function formatDuration(seconds) { + const safe = Math.max(0, Math.floor(seconds || 0)); + if (safe >= 3600) { + return `${(safe / 3600).toFixed(1)}h`; + } + return `${Math.round(safe / 60)}m`; +} + +export function kindLabel(kind) { + switch (kind) { + case 'business': + return 'Business'; + case 'residential': + return 'Residential'; + case 'restaurant': + return 'Restaurant'; + default: + return 'Other'; + } +} + +export function iconForKind(kind) { + switch (kind) { + case 'business': + return 'fa-building'; + case 'residential': + return 'fa-house'; + case 'restaurant': + return 'fa-utensils'; + default: + return 'fa-box'; + } +} + +export function toneForKind(kind) { + switch (kind) { + case 'business': + return 'blue'; + case 'residential': + return 'emerald'; + case 'restaurant': + return 'amber'; + default: + return 'slate'; + } +} diff --git a/static/app/models/preview.mjs b/static/app/models/preview.mjs new file mode 100644 index 0000000000000000000000000000000000000000..56eeacae4e383a66c4be4b3e194586e01d25545c --- /dev/null +++ b/static/app/models/preview.mjs @@ -0,0 +1,152 @@ +const AVERAGE_SPEED_KMPH = 50; +const UNASSIGNED_DELIVERY_HARD_PENALTY = 1_000_000; + +export function buildPreview(plan) { + const assigned = new Set(); + const vehicles = []; + const deliveries = (plan.deliveries || []).map((delivery) => ({ + deliveryId: delivery.id, + label: delivery.label, + kind: delivery.kind, + demand: delivery.demand, + minStartTime: delivery.minStartTime, + maxEndTime: delivery.maxEndTime, + serviceDuration: delivery.serviceDuration, + assignedVehicleId: null, + assignedVehicleName: null, + sequence: null, + arrivalTime: null, + serviceStartTime: null, + departureTime: null, + lateSeconds: null, + })); + + let capacityOverage = 0; + let lateSeconds = 0; + let travelSeconds = 0; + + for (const vehicle of plan.vehicles || []) { + const metrics = computeVehicleMetrics(plan, vehicle); + capacityOverage += metrics.capacityOverage; + lateSeconds += metrics.totalLateSeconds; + travelSeconds += metrics.totalTravelSeconds; + vehicles.push(metrics); + + for (const stop of metrics.stops) { + assigned.add(stop.deliveryId); + const delivery = deliveries[stop.deliveryId]; + delivery.assignedVehicleId = vehicle.id; + delivery.assignedVehicleName = vehicle.name; + delivery.sequence = stop.sequence; + delivery.arrivalTime = stop.arrivalTime; + delivery.serviceStartTime = stop.serviceStartTime; + delivery.departureTime = stop.departureTime; + delivery.lateSeconds = stop.lateSeconds; + } + } + + const unassignedDeliveryIds = deliveries + .filter((delivery) => !assigned.has(delivery.deliveryId)) + .map((delivery) => delivery.deliveryId); + + return { + hardScore: -( + unassignedDeliveryIds.length * UNASSIGNED_DELIVERY_HARD_PENALTY + + capacityOverage + + lateSeconds + ), + softScore: -travelSeconds, + unassignedDeliveryIds, + vehicles, + deliveries, + }; +} + +function computeVehicleMetrics(plan, vehicle) { + const stops = []; + let totalDemand = 0; + let totalTravelSeconds = 0; + let totalWaitSeconds = 0; + let totalServiceSeconds = 0; + let totalLateSeconds = 0; + let endTime = vehicle.departureTime || 0; + let currentTime = vehicle.departureTime || 0; + let previous = { lat: vehicle.homeLat, lng: vehicle.homeLng }; + + for (const [sequence, deliveryId] of (vehicle.deliveryOrder || []).entries()) { + const delivery = plan.deliveries[deliveryId]; + if (!delivery) continue; + + totalDemand += Number(delivery.demand || 0); + const travel = estimateTravel(previous, delivery); + totalTravelSeconds += travel; + const arrivalTime = currentTime + travel; + const serviceStartTime = Math.max(arrivalTime, delivery.minStartTime || 0); + const waitSeconds = Math.max(0, serviceStartTime - arrivalTime); + const departureTime = serviceStartTime + Number(delivery.serviceDuration || 0); + const lateSeconds = Math.max(0, departureTime - Number(delivery.maxEndTime || 0)); + + totalWaitSeconds += waitSeconds; + totalServiceSeconds += Number(delivery.serviceDuration || 0); + totalLateSeconds += lateSeconds; + currentTime = departureTime; + endTime = departureTime; + previous = delivery; + + stops.push({ + deliveryId, + label: delivery.label, + kind: delivery.kind, + sequence, + demand: delivery.demand, + minStartTime: delivery.minStartTime, + maxEndTime: delivery.maxEndTime, + arrivalTime, + serviceStartTime, + departureTime, + travelSecondsFromPrevious: travel, + waitSeconds, + lateSeconds, + }); + } + + if (stops.length) { + const depot = { lat: vehicle.homeLat, lng: vehicle.homeLng }; + const returnTravel = estimateTravel(previous, depot); + totalTravelSeconds += returnTravel; + endTime = currentTime + returnTravel; + } + + return { + vehicleId: vehicle.id, + vehicleName: vehicle.name, + totalDemand, + capacityOverage: Math.max(0, totalDemand - Number(vehicle.capacity || 0)), + stopCount: stops.length, + totalTravelSeconds, + totalWaitSeconds, + totalServiceSeconds, + totalLateSeconds, + startTime: vehicle.departureTime || 0, + endTime, + stops, + }; +} + +function estimateTravel(from, to) { + const meters = haversineMeters(Number(from.lat), Number(from.lng), Number(to.lat), Number(to.lng)); + const metersPerSecond = (AVERAGE_SPEED_KMPH * 1000) / 3600; + return Math.round(meters / metersPerSecond); +} + +function haversineMeters(lat1, lng1, lat2, lng2) { + const toRad = (value) => (value * Math.PI) / 180; + const r = 6371000; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * + Math.sin(dLng / 2) * Math.sin(dLng / 2); + return 2 * r * Math.asin(Math.sqrt(a)); +} diff --git a/static/app/models/timeline.mjs b/static/app/models/timeline.mjs new file mode 100644 index 0000000000000000000000000000000000000000..96603cb3f8b74eefe098eba940410d6cf461ae18 --- /dev/null +++ b/static/app/models/timeline.mjs @@ -0,0 +1,94 @@ +import { formatClock, kindLabel, toneForKind } from './formatters.mjs'; + +export function buildVehicleTimelineModel(preview) { + const axis = buildAxis(preview); + return { + axis, + lanes: (preview?.vehicles || []).map((vehicle) => ({ + id: `vehicle-${vehicle.vehicleId}`, + label: vehicle.vehicleName, + mode: 'detailed', + badges: [ + `${vehicle.stopCount} stops`, + `${vehicle.totalDemand}/${vehicle.totalDemand - vehicle.capacityOverage + vehicle.capacityOverage || vehicle.totalDemand}`, + ], + items: vehicle.stops.map((stop) => ({ + id: `vehicle-${vehicle.vehicleId}-stop-${stop.deliveryId}`, + startMinute: Math.floor(stop.serviceStartTime / 60), + endMinute: Math.ceil(stop.departureTime / 60), + label: stop.label, + meta: `${kindLabel(stop.kind)} · ${formatClock(stop.arrivalTime)} arrival`, + tone: toneForKind(stop.kind), + })), + })), + }; +} + +export function buildDeliveryTimelineModel(preview) { + const axis = buildAxis(preview); + return { + axis, + lanes: (preview?.deliveries || []).map((delivery) => { + const items = [ + { + id: `delivery-window-${delivery.deliveryId}`, + startMinute: Math.floor(delivery.minStartTime / 60), + endMinute: Math.ceil(delivery.maxEndTime / 60), + label: 'Window', + meta: `${formatClock(delivery.minStartTime)} to ${formatClock(delivery.maxEndTime)}`, + tone: 'slate', + }, + ]; + + if (delivery.serviceStartTime != null && delivery.departureTime != null) { + items.push({ + id: `delivery-service-${delivery.deliveryId}`, + startMinute: Math.floor(delivery.serviceStartTime / 60), + endMinute: Math.ceil(delivery.departureTime / 60), + label: delivery.assignedVehicleName || 'Assigned', + meta: `${delivery.label} · ${kindLabel(delivery.kind)}`, + tone: toneForKind(delivery.kind), + }); + } + + return { + id: `delivery-${delivery.deliveryId}`, + label: delivery.label, + mode: 'detailed', + badges: [kindLabel(delivery.kind), delivery.assignedVehicleName || 'Unassigned'], + items, + }; + }), + }; +} + +function buildAxis(preview) { + let minMinute = 6 * 60; + let maxMinute = 21 * 60; + const previewData = preview || { deliveries: [], vehicles: [] }; + + for (const delivery of previewData.deliveries || []) { + minMinute = Math.min(minMinute, Math.floor((delivery.minStartTime || 0) / 60) - 30); + maxMinute = Math.max(maxMinute, Math.ceil((delivery.maxEndTime || 0) / 60) + 30); + } + for (const vehicle of previewData.vehicles || []) { + minMinute = Math.min(minMinute, Math.floor((vehicle.startTime || 0) / 60) - 15); + maxMinute = Math.max(maxMinute, Math.ceil((vehicle.endTime || 0) / 60) + 15); + } + + minMinute = Math.max(0, minMinute); + maxMinute = Math.min(24 * 60, Math.max(minMinute + 120, maxMinute)); + + const ticks = []; + for (let minute = minMinute; minute <= maxMinute; minute += 60) { + ticks.push({ minute, label: formatClock(minute * 60) }); + } + + return { + startMinute: minMinute, + endMinute: maxMinute, + days: [{ dayIndex: 0, label: 'Day 1' }], + ticks, + initialViewport: { startMinute: minMinute, endMinute: maxMinute }, + }; +} diff --git a/static/app/ui/api-guide.mjs b/static/app/ui/api-guide.mjs new file mode 100644 index 0000000000000000000000000000000000000000..507ad03a1a6d364a0ebc3117dbeeb5533bf97986 --- /dev/null +++ b/static/app/ui/api-guide.mjs @@ -0,0 +1,29 @@ +export function buildApiGuideEndpoints() { + return [ + endpoint('GET', '/health', 'Check service health'), + endpoint('GET', '/info', 'Fetch app and solver metadata'), + endpoint('GET', '/demo-data', 'List available city demos'), + endpoint('GET', '/demo-data/PHILADELPHIA', 'Load a canonical delivery plan'), + endpoint('POST', '/jobs', 'Start a retained solve job', true, '@plan.json'), + endpoint('GET', '/jobs/{id}', 'Fetch retained job status'), + endpoint('GET', '/jobs/{id}/status', 'Fetch retained job status through the stock UI alias'), + endpoint('GET', '/jobs/{id}/snapshot', 'Fetch the latest retained snapshot'), + endpoint('GET', '/jobs/{id}/analysis?snapshot_revision={n}', 'Analyze an exact snapshot revision'), + endpoint('GET', '/jobs/{id}/routes?snapshot_revision={n}', 'Fetch encoded route geometry for a retained snapshot'), + endpoint('POST', '/jobs/{id}/pause', 'Pause a retained job'), + endpoint('POST', '/jobs/{id}/resume', 'Resume a retained job'), + endpoint('POST', '/jobs/{id}/cancel', 'Cancel a retained job'), + endpoint('DELETE', '/jobs/{id}', 'Delete a terminal retained job'), + endpoint('GET', '/jobs/{id}/events', 'Stream retained job events'), + endpoint('POST', '/recommendations/delivery-insertions', 'Rank candidate insertions for one delivery', true), + ]; +} + +function endpoint(method, path, description, json = false, data = null) { + const parts = ['curl']; + if (method !== 'GET') parts.push('-X', method); + if (json) parts.push('-H', '"Content-Type: application/json"'); + parts.push(`${window.location.origin}${path}`); + if (data) parts.push('-d', data); + return { method, path, description, curl: parts.join(' ') }; +} diff --git a/static/app/ui/components.mjs b/static/app/ui/components.mjs new file mode 100644 index 0000000000000000000000000000000000000000..e7279db6759d7ca1a4c427b8c4f5027a87cfb536 --- /dev/null +++ b/static/app/ui/components.mjs @@ -0,0 +1,44 @@ +export function mountPanel(root, panels, id) { + const panel = SF.el('section', { + className: `deliveries-panel${id === 'overview' ? ' is-active' : ''}`, + }); + panels[id] = panel; + root.appendChild(panel); + return panel; +} + +export function createSelectField(label, options) { + const select = SF.el('select'); + options.forEach((option) => { + select.appendChild(SF.el('option', { value: option.value }, option.label)); + }); + const el = SF.el( + 'div', + { className: 'deliveries-field' }, + SF.el('label', null, label), + select, + ); + return { el, select }; +} + +export function kpi(label, value) { + return SF.el( + 'div', + { className: 'deliveries-kpi' }, + SF.el('div', { className: 'deliveries-kpi__label' }, label), + SF.el('div', { className: 'deliveries-kpi__value' }, value), + ); +} + +export function showError(error) { + const detail = error?.message || String(error); + SF.showError('Deliveries UI', detail); +} + +export async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed: ${url}`); + } + return response.json(); +} diff --git a/static/app/ui/data-tables.mjs b/static/app/ui/data-tables.mjs new file mode 100644 index 0000000000000000000000000000000000000000..f419051716c2e318a661c92b95f991c0de57caba --- /dev/null +++ b/static/app/ui/data-tables.mjs @@ -0,0 +1,98 @@ +import { formatClock, kindLabel } from '../models.mjs'; + +export function renderData({ currentPlan, dataBody, onRecommend }) { + dataBody.innerHTML = ''; + dataBody.appendChild(buildVehicleTable(currentPlan)); + dataBody.appendChild(buildDeliveryTable(currentPlan, onRecommend)); +} + +function buildVehicleTable(currentPlan) { + const wrapper = SF.el('div'); + wrapper.appendChild(SF.el('h4', null, 'Vehicles')); + const table = SF.el('table', { className: 'deliveries-table' }); + table.appendChild( + SF.el( + 'thead', + null, + SF.el( + 'tr', + null, + SF.el('th', null, 'Name'), + SF.el('th', null, 'Depot'), + SF.el('th', null, 'Capacity'), + SF.el('th', null, 'Departure'), + ), + ), + ); + const body = SF.el('tbody'); + currentPlan.vehicles.forEach((vehicle) => { + body.appendChild( + SF.el( + 'tr', + null, + SF.el('td', null, vehicle.name), + SF.el('td', null, `${vehicle.homeLat.toFixed(4)}, ${vehicle.homeLng.toFixed(4)}`), + SF.el('td', null, String(vehicle.capacity)), + SF.el('td', null, formatClock(vehicle.departureTime)), + ), + ); + }); + table.appendChild(body); + wrapper.appendChild(table); + return wrapper; +} + +function buildDeliveryTable(currentPlan, onRecommend) { + const wrapper = SF.el('div'); + wrapper.appendChild(SF.el('h4', null, 'Deliveries')); + const table = SF.el('table', { className: 'deliveries-table' }); + table.appendChild(buildDeliveryHeader()); + const body = SF.el('tbody'); + const previewDeliveries = currentPlan.viewState.preview.deliveries; + previewDeliveries.forEach((deliveryPreview) => { + const recommendButton = SF.createButton({ text: 'Recommend', variant: 'ghost', size: 'small' }); + recommendButton.addEventListener('click', () => onRecommend(deliveryPreview.deliveryId)); + body.appendChild(buildDeliveryRow(deliveryPreview, recommendButton)); + }); + table.appendChild(body); + wrapper.appendChild(table); + return wrapper; +} + +function buildDeliveryHeader() { + return SF.el( + 'thead', + null, + SF.el( + 'tr', + null, + SF.el('th', null, 'Label'), + SF.el('th', null, 'Kind'), + SF.el('th', null, 'Window'), + SF.el('th', null, 'Demand'), + SF.el('th', null, 'Assignment'), + SF.el('th', null, 'Actions'), + ), + ); +} + +function buildDeliveryRow(deliveryPreview, recommendButton) { + return SF.el( + 'tr', + null, + SF.el('td', null, deliveryPreview.label), + SF.el('td', null, kindLabel(deliveryPreview.kind)), + SF.el( + 'td', + null, + `${formatClock(deliveryPreview.minStartTime)} to ${formatClock(deliveryPreview.maxEndTime)}`, + ), + SF.el('td', null, String(deliveryPreview.demand)), + SF.el('td', null, deliveryPreview.assignedVehicleName || 'Unassigned'), + SF.el( + 'td', + null, + SF.el('div', { className: 'deliveries-actions-inline' }, recommendButton), + ), + ); +} diff --git a/static/app/ui/layout.mjs b/static/app/ui/layout.mjs new file mode 100644 index 0000000000000000000000000000000000000000..a75ad1bf4922e36c875c73c8c9b7870dcfce93d2 --- /dev/null +++ b/static/app/ui/layout.mjs @@ -0,0 +1,123 @@ +import { buildDeliveryTimelineModel, buildVehicleTimelineModel } from '../models.mjs'; +import { buildApiGuideEndpoints } from './api-guide.mjs'; +import { createSelectField, mountPanel } from './components.mjs'; + +export function createLayout({ app, config, statusBar, actions, onTabChange }) { + const panels = {}; + const header = SF.createHeader({ + logo: '/sf/img/ouroboros.svg', + title: config.title, + subtitle: config.subtitle, + tabs: [ + { id: 'overview', label: 'Overview', icon: 'fa-map', active: true }, + { id: 'vehicleTimeline', label: 'By Vehicle', icon: 'fa-truck' }, + { id: 'deliveryTimeline', label: 'By Delivery', icon: 'fa-box' }, + { id: 'data', label: 'Data', icon: 'fa-table' }, + { id: 'api', label: 'REST API', icon: 'fa-book' }, + ], + actions, + onTabChange, + }); + app.appendChild(header); + statusBar.bindHeader(header); + app.appendChild(statusBar.el); + + const shell = SF.el('div', { className: 'sf-content deliveries-shell' }); + app.appendChild(shell); + const controls = createControls(shell); + const overview = createOverview(shell, panels); + const timelines = createTimelines(shell, panels); + const data = createDataPanel(shell, panels); + createApiPanel(shell, panels); + + return { + ...controls, + ...overview, + ...timelines, + ...data, + panels, + analysisModal: SF.createModal({ title: 'Score Analysis', width: '760px' }), + recommendationModal: SF.createModal({ + title: 'Delivery Insertion Recommendations', + width: '820px', + }), + }; +} + +function createControls(shell) { + const controlsCard = SF.el('section', { className: 'deliveries-card' }); + const controlsRow = SF.el('div', { className: 'deliveries-controls' }); + const demoField = createSelectField('Demo Data', [ + { value: 'PHILADELPHIA', label: 'Philadelphia' }, + { value: 'HARTFORD', label: 'Hartford' }, + { value: 'FIRENZE', label: 'Firenze' }, + ]); + demoField.select.value = 'PHILADELPHIA'; + const routingField = createSelectField('Routing Mode', [ + { value: 'road_network', label: 'Road Network' }, + { value: 'straight_line', label: 'Straight Line' }, + ]); + routingField.select.value = 'road_network'; + const reloadButton = SF.createButton({ text: 'Reload Demo', icon: 'fa-rotate-right', variant: 'default' }); + + controlsRow.appendChild(demoField.el); + controlsRow.appendChild(routingField.el); + controlsRow.appendChild(reloadButton); + controlsCard.appendChild(controlsRow); + shell.appendChild(controlsCard); + return { demoField, routingField, reloadButton }; +} + +function createOverview(shell, panels) { + const overviewPanel = mountPanel(shell, panels, 'overview'); + const overviewGrid = SF.el('div', { className: 'deliveries-grid' }); + overviewPanel.appendChild(overviewGrid); + const leftStack = SF.el('div', { className: 'deliveries-stack' }); + const summaryCard = SF.el('section', { className: 'deliveries-card' }); + const summaryMetrics = SF.el('div', { className: 'deliveries-kpis' }); + const routeListCard = SF.el('section', { className: 'deliveries-card' }); + const routeList = SF.el('div', { className: 'deliveries-list' }); + routeListCard.appendChild(SF.el('h3', null, 'Routes')); + routeListCard.appendChild(routeList); + leftStack.appendChild(summaryCard); + leftStack.appendChild(routeListCard); + overviewGrid.appendChild(leftStack); + + const mapCard = SF.el('section', { className: 'deliveries-card deliveries-map-card' }); + mapCard.appendChild(SF.el('h3', null, 'Map')); + const mapEl = SF.el('div', { className: 'deliveries-map', id: 'deliveries-map' }); + mapCard.appendChild(mapEl); + overviewGrid.appendChild(mapCard); + return { summaryCard, summaryMetrics, routeList }; +} + +function createTimelines(shell, panels) { + const vehicleTimelinePanel = mountPanel(shell, panels, 'vehicleTimeline'); + const vehicleTimelineCard = SF.el('section', { className: 'deliveries-card' }); + vehicleTimelineCard.appendChild(SF.el('h3', null, 'Schedule By Vehicle')); + vehicleTimelinePanel.appendChild(vehicleTimelineCard); + const deliveryTimelinePanel = mountPanel(shell, panels, 'deliveryTimeline'); + const deliveryTimelineCard = SF.el('section', { className: 'deliveries-card' }); + deliveryTimelineCard.appendChild(SF.el('h3', null, 'Schedule By Delivery')); + deliveryTimelinePanel.appendChild(deliveryTimelineCard); + const vehicleTimeline = SF.rail.createTimeline({ label: 'By vehicle', labelWidth: 260, model: buildVehicleTimelineModel(null) }); + const deliveryTimeline = SF.rail.createTimeline({ label: 'By delivery', labelWidth: 320, model: buildDeliveryTimelineModel(null) }); + vehicleTimelineCard.appendChild(vehicleTimeline.el); + deliveryTimelineCard.appendChild(deliveryTimeline.el); + return { vehicleTimeline, deliveryTimeline }; +} + +function createDataPanel(shell, panels) { + const dataPanel = mountPanel(shell, panels, 'data'); + const dataCard = SF.el('section', { className: 'deliveries-card' }); + const dataBody = SF.el('div'); + dataCard.appendChild(SF.el('h3', null, 'Draft Data')); + dataCard.appendChild(dataBody); + dataPanel.appendChild(dataCard); + return { dataBody }; +} + +function createApiPanel(shell, panels) { + const apiPanel = mountPanel(shell, panels, 'api'); + apiPanel.appendChild(SF.createApiGuide({ endpoints: buildApiGuideEndpoints() })); +} diff --git a/static/app/ui/lifecycle.mjs b/static/app/ui/lifecycle.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2ebfa98495e43b663f3441591686e769f560563d --- /dev/null +++ b/static/app/ui/lifecycle.mjs @@ -0,0 +1,23 @@ +export function syncLifecycleDataset(app, solver, meta) { + const jobId = solver.getJobId(); + const snapshotRevision = + meta?.snapshotRevision != null ? meta.snapshotRevision : solver.getSnapshotRevision(); + 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; + } +} + +export function sameRouteIdentity(left, right) { + return ( + left?.jobId === right?.jobId && + left?.snapshotRevision === right?.snapshotRevision && + left?.routingMode === right?.routingMode + ); +} diff --git a/static/app/ui/modals.mjs b/static/app/ui/modals.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d0347c54eda87cc1d21ee6f870329d7204afd413 --- /dev/null +++ b/static/app/ui/modals.mjs @@ -0,0 +1,63 @@ +export function buildRecommendationBody(payload, onApply) { + const container = SF.el('div', { className: 'deliveries-modal-list' }); + if (!payload.candidates.length) { + container.appendChild(SF.el('div', { className: 'deliveries-empty' }, 'No valid insertions.')); + return container; + } + + payload.candidates.forEach((candidate) => { + const row = SF.el('div', { className: 'deliveries-list__row' }); + const top = SF.el('div', { className: 'deliveries-list__top' }); + top.appendChild(SF.el('strong', null, `${candidate.vehicleName} · position ${candidate.insertIndex + 1}`)); + top.appendChild(SF.el('span', { className: 'deliveries-tag' }, candidate.score)); + row.appendChild(top); + + const meta = SF.el('div', { className: 'deliveries-list__meta' }); + meta.appendChild(SF.el('span', null, `Δ hard ${candidate.deltaHard}`)); + meta.appendChild(SF.el('span', null, `Δ soft ${candidate.deltaSoft}`)); + row.appendChild(meta); + + const applyButton = SF.createButton({ text: 'Apply', variant: 'success' }); + applyButton.addEventListener('click', () => onApply(candidate.previewPlan)); + row.appendChild(SF.el('div', { className: 'deliveries-list__actions' }, applyButton)); + container.appendChild(row); + }); + + return container; +} + +export function buildAnalysisBody(analysis) { + const container = SF.el('div'); + container.appendChild(SF.el('p', null, `Score: ${analysis.analysis.score}`)); + const table = SF.el('table', { className: 'deliveries-table' }); + table.appendChild( + SF.el( + 'thead', + null, + SF.el( + 'tr', + null, + SF.el('th', null, 'Constraint'), + SF.el('th', null, 'Weight'), + SF.el('th', null, 'Score'), + SF.el('th', null, 'Matches'), + ), + ), + ); + const body = SF.el('tbody'); + (analysis.analysis.constraints || []).forEach((constraint) => { + body.appendChild( + SF.el( + 'tr', + null, + SF.el('td', null, constraint.name), + SF.el('td', null, constraint.weight), + SF.el('td', null, constraint.score), + SF.el('td', null, String(constraint.matchCount)), + ), + ); + }); + table.appendChild(body); + container.appendChild(table); + return container; +} diff --git a/static/app/ui/overview.mjs b/static/app/ui/overview.mjs new file mode 100644 index 0000000000000000000000000000000000000000..164674671d14a0bbd05c6c0ecb9e2ecbd7d2072d --- /dev/null +++ b/static/app/ui/overview.mjs @@ -0,0 +1,172 @@ +import { + buildDeliveryTimelineModel, + buildVehicleTimelineModel, + formatClock, + formatDuration, + iconForKind, +} from '../models.mjs'; +import { kpi } from './components.mjs'; + +const VEHICLE_COLORS = [ + '#10b981', + '#3b82f6', + '#8b5cf6', + '#f59e0b', + '#ec4899', + '#06b6d4', + '#f43f5e', + '#84cc16', + '#14b8a6', + '#a855f7', +]; + +export function colorForVehicle(vehicleId) { + const index = Number(vehicleId); + const normalized = Number.isFinite(index) ? Math.abs(Math.trunc(index)) : 0; + return VEHICLE_COLORS[normalized % VEHICLE_COLORS.length]; +} + +export function routeStyleForVehicle(vehicleId, focusedVehicleId) { + const color = colorForVehicle(vehicleId); + if (focusedVehicleId == null) return { color, opacity: 0.8, weight: 3 }; + return Number(vehicleId) === Number(focusedVehicleId) + ? { color, opacity: 1, weight: 5 } + : { color, opacity: 0.2, weight: 2 }; +} + +export function renderSummary({ currentDemo, currentPlan, summaryCard, summaryMetrics }) { + const preview = currentPlan?.viewState?.preview; + summaryCard.innerHTML = ''; + summaryCard.appendChild(SF.el('h3', null, currentPlan?.name || 'Delivery Draft')); + summaryCard.appendChild( + SF.el( + 'p', + null, + `${currentDemo} · ${currentPlan.routingMode.replace('_', ' ')} · ${currentPlan.deliveries.length} deliveries · ${currentPlan.vehicles.length} vehicles`, + ), + ); + + summaryMetrics.innerHTML = ''; + summaryMetrics.appendChild(kpi('Hard Score', String(preview?.hardScore ?? 0))); + summaryMetrics.appendChild(kpi('Soft Score', String(preview?.softScore ?? 0))); + summaryMetrics.appendChild(kpi('Unassigned', String(preview?.unassignedDeliveryIds?.length || 0))); + summaryMetrics.appendChild( + kpi( + 'Travel', + formatDuration( + (preview?.vehicles || []).reduce((sum, vehicle) => sum + (vehicle.totalTravelSeconds || 0), 0), + ), + ), + ); + summaryCard.appendChild(summaryMetrics); +} + +export function renderRouteList({ currentPlan, focusedVehicleId, routeList, onFocusVehicle }) { + routeList.innerHTML = ''; + const previewVehicles = currentPlan?.viewState?.preview?.vehicles || []; + if (!previewVehicles.length) { + routeList.appendChild(SF.el('div', { className: 'deliveries-empty' }, 'No routes yet.')); + return; + } + + for (const vehicle of previewVehicles) { + const isFocused = focusedVehicleId === vehicle.vehicleId; + const row = SF.el('div', { + className: `deliveries-list__row${isFocused ? ' is-focused' : ''}`, + role: 'button', + tabIndex: 0, + }); + row.addEventListener('click', () => onFocusVehicle(vehicle.vehicleId)); + row.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onFocusVehicle(vehicle.vehicleId); + } + }); + const top = SF.el('div', { className: 'deliveries-list__top' }); + top.appendChild(SF.el('strong', null, vehicle.vehicleName)); + top.appendChild(SF.el('span', { className: 'deliveries-tag' }, `${vehicle.stopCount} stops`)); + row.appendChild(top); + + const meta = SF.el('div', { className: 'deliveries-list__meta' }); + meta.appendChild(SF.el('span', null, `${vehicle.totalDemand} units`)); + meta.appendChild(SF.el('span', null, formatDuration(vehicle.totalTravelSeconds))); + meta.appendChild(SF.el('span', null, `${formatClock(vehicle.startTime)} to ${formatClock(vehicle.endTime)}`)); + if (vehicle.totalLateSeconds > 0) { + meta.appendChild(SF.el('span', null, `${formatDuration(vehicle.totalLateSeconds)} late`)); + } + row.appendChild(meta); + + const focusButton = SF.createButton({ + text: isFocused ? 'Show All' : 'Highlight', + variant: isFocused ? 'default' : 'ghost', + }); + focusButton.addEventListener('click', (event) => { + event.stopPropagation(); + onFocusVehicle(vehicle.vehicleId); + }); + row.appendChild(SF.el('div', { className: 'deliveries-list__actions' }, focusButton)); + routeList.appendChild(row); + } +} + +export function renderMap({ currentPlan, currentRoutes, focusedVehicleId, fitBounds, mapCtrl, setMapCtrl }) { + if (!mapCtrl) { + mapCtrl = SF.map.create({ + container: 'deliveries-map', + center: [currentPlan?.vehicles?.[0]?.homeLat || 39.9526, currentPlan?.vehicles?.[0]?.homeLng || -75.1652], + zoom: 12, + }); + setMapCtrl(mapCtrl); + } + mapCtrl.clearAll(); + + const vehicles = currentPlan?.vehicles || []; + const deliveries = currentPlan?.deliveries || []; + const previewDeliveries = currentPlan?.viewState?.preview?.deliveries || []; + vehicles.forEach((vehicle) => { + mapCtrl.addVehicleMarker({ + lat: vehicle.homeLat, + lng: vehicle.homeLng, + color: colorForVehicle(vehicle.id), + }); + }); + + previewDeliveries.forEach((delivery) => { + mapCtrl.addVisitMarker({ + lat: deliveries[delivery.deliveryId].lat, + lng: deliveries[delivery.deliveryId].lng, + color: delivery.assignedVehicleId == null ? '#9ca3af' : colorForVehicle(delivery.assignedVehicleId), + icon: iconForKind(delivery.kind), + assigned: delivery.assignedVehicleId != null, + }); + }); + + const drawableRoutes = currentRoutes?.routingMode === currentPlan?.routingMode ? currentRoutes : null; + if (drawableRoutes?.vehicles?.length) { + drawRoutes(mapCtrl, drawableRoutes, focusedVehicleId); + } + if (fitBounds) { + mapCtrl.fitBounds(); + } +} + +export function renderTimelines({ currentPlan, vehicleTimeline, deliveryTimeline }) { + const preview = currentPlan?.viewState?.preview || null; + vehicleTimeline.setModel(buildVehicleTimelineModel(preview)); + deliveryTimeline.setModel(buildDeliveryTimelineModel(preview)); +} + +function drawRoutes(mapCtrl, drawableRoutes, focusedVehicleId) { + drawableRoutes.vehicles.forEach((vehicleRoute) => { + const style = routeStyleForVehicle(vehicleRoute.vehicleId, focusedVehicleId); + vehicleRoute.segments.forEach((segment) => { + mapCtrl.drawEncodedRoute({ + encoded: segment.encodedPolyline, + color: style.color, + opacity: style.opacity, + weight: style.weight, + }); + }); + }); +} diff --git a/static/generated/ui-model.json b/static/generated/ui-model.json new file mode 100644 index 0000000000000000000000000000000000000000..fa0b7c8495e080e582ed6a625f47000e7eab3b5d --- /dev/null +++ b/static/generated/ui-model.json @@ -0,0 +1,25 @@ +{ + "constraints": [ + "all_deliveries_assigned", + "vehicle_capacity", + "delivery_time_windows", + "total_travel_time" + ], + "views": [ + { + "id": "overview", + "label": "Overview", + "kind": "custom" + }, + { + "id": "vehicleTimeline", + "label": "By Vehicle", + "kind": "list" + }, + { + "id": "deliveryTimeline", + "label": "By Delivery", + "kind": "list" + } + ] +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e8f46f0a2a69315f76350a82e448a4d97cb4d498 --- /dev/null +++ b/static/index.html @@ -0,0 +1,22 @@ + + + + + + SolverForge Deliveries + + + + + + + + + +
+ + + + + + diff --git a/static/sf-config.json b/static/sf-config.json new file mode 100644 index 0000000000000000000000000000000000000000..f47722f3c4fb35054ca0998035d7296021caf0bb --- /dev/null +++ b/static/sf-config.json @@ -0,0 +1,4 @@ +{ + "title": "SolverForge Deliveries", + "subtitle": "Retained delivery-route optimization with route previews" +} diff --git a/tests/api_contract.rs b/tests/api_contract.rs new file mode 100644 index 0000000000000000000000000000000000000000..40a706d331d226ca733a679cc6c9e9f039464a0a --- /dev/null +++ b/tests/api_contract.rs @@ -0,0 +1,10 @@ +mod support; + +#[path = "api_contract/catalog.rs"] +mod catalog; +#[path = "api_contract/jobs.rs"] +mod jobs; +#[path = "api_contract/lifecycle.rs"] +mod lifecycle; +#[path = "api_contract/sse.rs"] +mod sse; diff --git a/tests/api_contract/catalog.rs b/tests/api_contract/catalog.rs new file mode 100644 index 0000000000000000000000000000000000000000..71468cdc5c8c8a31dd1dfc211348aba7669e96dc --- /dev/null +++ b/tests/api_contract/catalog.rs @@ -0,0 +1,88 @@ +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use crate::support::api::{read_json, small_plan, test_app}; + +#[tokio::test] +async fn demo_data_and_static_assets_match_current_contract() { + let app = test_app(); + + let demo_response = app + .clone() + .oneshot(Request::get("/demo-data").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(demo_response.status(), StatusCode::OK); + let demos = read_json(demo_response).await; + assert_eq!( + demos, + serde_json::json!(["PHILADELPHIA", "HARTFORD", "FIRENZE"]) + ); + + let demo_plan_response = app + .clone() + .oneshot( + Request::get("/demo-data/PHILADELPHIA") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(demo_plan_response.status(), StatusCode::OK); + let demo_plan = read_json(demo_plan_response).await; + assert_eq!(demo_plan["name"], "Philadelphia"); + assert_eq!(demo_plan["routingMode"], "road_network"); + assert!(!demo_plan["viewState"]["preview"]["vehicles"] + .as_array() + .expect("preview vehicles should exist") + .is_empty()); + + let asset_response = app + .clone() + .oneshot(Request::get("/sf/sf.js").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(asset_response.status(), StatusCode::OK); + + let no_legacy_response = app + .clone() + .oneshot(Request::get("/webjars/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(no_legacy_response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn recommendations_endpoint_returns_ranked_preview_plans() { + let app = test_app(); + let mut plan = small_plan(); + if let Some(order) = plan["vehicles"][0]["deliveryOrder"].as_array_mut() { + order.clear(); + } + + let response = app + .clone() + .oneshot( + Request::post("/recommendations/delivery-insertions") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "plan": plan, + "deliveryId": 0, + "limit": 5 + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + let candidates = json["candidates"] + .as_array() + .expect("candidates should be an array"); + assert!(!candidates.is_empty()); + assert!(candidates[0]["previewPlan"]["viewState"]["preview"].is_object()); +} diff --git a/tests/api_contract/jobs.rs b/tests/api_contract/jobs.rs new file mode 100644 index 0000000000000000000000000000000000000000..8d93106b630193edff2ca94b60481b14fec385d4 --- /dev/null +++ b/tests/api_contract/jobs.rs @@ -0,0 +1,214 @@ +use std::time::{Duration, Instant}; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use crate::support::api::{ + assert_score_text, assert_score_value, completion_plan, empty_road_network_plan, + poll_job_state, read_body_text, read_json, test_app, +}; + +#[tokio::test] +async fn straight_line_job_emits_a_non_empty_snapshot_before_cancellation() { + let app = test_app(); + let create_response = app + .clone() + .oneshot( + Request::post("/jobs") + .header("content-type", "application/json") + .body(Body::from(completion_plan().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(create_response.status(), StatusCode::OK); + let created = read_json(create_response).await; + let job_id = created["id"].as_str().expect("job id should be a string"); + + let snapshot = poll_for_routed_snapshot(&app, job_id).await; + let snapshot_revision = snapshot["snapshotRevision"] + .as_u64() + .expect("snapshot revision should exist"); + let solution_score = assert_score_value(&snapshot["solution"]["score"], "solution.score"); + let current_score = snapshot["currentScore"].as_str(); + let best_score = snapshot["bestScore"].as_str(); + if let Some(score) = current_score { + assert_score_text(score, "currentScore"); + } + if let Some(score) = best_score { + assert_score_text(score, "bestScore"); + } + assert!( + current_score == Some(solution_score) || best_score == Some(solution_score), + "solution.score should match a displayed snapshot score" + ); + + let status_response = app + .clone() + .oneshot( + Request::get(format!("/jobs/{job_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(status_response.status(), StatusCode::OK); + let status = read_json(status_response).await; + assert!( + status["telemetry"]["stepCount"].as_u64().unwrap_or(0) > 0, + "solver should have taken at least one step before cancellation" + ); + + assert_routes_have_segments(&app, job_id, snapshot_revision).await; + let cancel_response = app + .clone() + .oneshot( + Request::post(format!("/jobs/{job_id}/cancel")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(cancel_response.status(), StatusCode::ACCEPTED); + let _ = poll_job_state(&app, job_id, "CANCELLED").await; +} + +#[tokio::test] +async fn empty_road_network_job_routes_return_empty_geometry() { + let app = test_app(); + let create_response = app + .clone() + .oneshot( + Request::post("/jobs") + .header("content-type", "application/json") + .body(Body::from(empty_road_network_plan().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(create_response.status(), StatusCode::OK); + let created = read_json(create_response).await; + let job_id = created["id"].as_str().expect("job id should be a string"); + + let snapshot = poll_for_any_snapshot(&app, job_id).await; + let snapshot_revision = snapshot["snapshotRevision"] + .as_u64() + .expect("snapshot revision should exist"); + + let routes_response = app + .clone() + .oneshot( + Request::get(format!( + "/jobs/{job_id}/routes?snapshot_revision={snapshot_revision}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(routes_response.status(), StatusCode::OK); + let routes_body = read_body_text(routes_response).await; + assert_eq!( + routes_body.matches("\"snapshotRevision\"").count(), + 1, + "routes response should expose snapshotRevision only once: {routes_body}" + ); + let routes: serde_json::Value = + serde_json::from_str(&routes_body).expect("routes body should be valid JSON"); + assert_eq!(routes["snapshotRevision"].as_u64(), Some(snapshot_revision)); + assert_eq!(routes["routingMode"], "road_network"); + assert!(routes["bounds"].is_null()); + assert_eq!( + routes["vehicles"] + .as_array() + .expect("routes vehicles should be an array") + .len(), + 0 + ); +} + +async fn poll_for_routed_snapshot(app: &axum::Router, job_id: &str) -> serde_json::Value { + let start = Instant::now(); + loop { + let response = app + .clone() + .oneshot( + Request::get(format!("/jobs/{job_id}/snapshot")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + if response.status() == StatusCode::OK { + let json = read_json(response).await; + let has_assigned_stops = json["solution"]["viewState"]["preview"]["vehicles"] + .as_array() + .expect("preview vehicles should be an array") + .iter() + .any(|vehicle| vehicle["stopCount"].as_u64().unwrap_or(0) > 0); + if has_assigned_stops { + return json; + } + } + if start.elapsed() > Duration::from_secs(6) { + panic!("job {job_id} did not emit a routed snapshot in time"); + } + tokio::time::sleep(Duration::from_millis(80)).await; + } +} + +async fn poll_for_any_snapshot(app: &axum::Router, job_id: &str) -> serde_json::Value { + let start = Instant::now(); + loop { + let response = app + .clone() + .oneshot( + Request::get(format!("/jobs/{job_id}/snapshot")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + if response.status() == StatusCode::OK { + return read_json(response).await; + } + if start.elapsed() > Duration::from_secs(6) { + panic!("empty job {job_id} did not emit a snapshot in time"); + } + tokio::time::sleep(Duration::from_millis(80)).await; + } +} + +async fn assert_routes_have_segments(app: &axum::Router, job_id: &str, revision: u64) { + let routes_response = app + .clone() + .oneshot( + Request::get(format!( + "/jobs/{job_id}/routes?snapshot_revision={revision}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(routes_response.status(), StatusCode::OK); + let routes_body = read_body_text(routes_response).await; + assert_eq!( + routes_body.matches("\"snapshotRevision\"").count(), + 1, + "routes response should expose snapshotRevision only on the response wrapper: {routes_body}" + ); + let routes: serde_json::Value = + serde_json::from_str(&routes_body).expect("routes body should be valid JSON"); + assert!(routes["vehicles"] + .as_array() + .expect("routes vehicles should be an array") + .iter() + .any(|vehicle| { + !vehicle["segments"] + .as_array() + .expect("segments should be an array") + .is_empty() + })); +} diff --git a/tests/api_contract/lifecycle.rs b/tests/api_contract/lifecycle.rs new file mode 100644 index 0000000000000000000000000000000000000000..81c58a46d0295ba2dec11a15ecc795c0984ef199 --- /dev/null +++ b/tests/api_contract/lifecycle.rs @@ -0,0 +1,139 @@ +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use crate::support::api::{bigger_plan, poll_job_state, read_json, test_app}; + +#[tokio::test] +async fn jobs_lifecycle_snapshot_analysis_routes_and_delete_work() { + let app = test_app(); + + let create_response = app + .clone() + .oneshot( + Request::post("/jobs") + .header("content-type", "application/json") + .body(Body::from(bigger_plan().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(create_response.status(), StatusCode::OK); + let created = read_json(create_response).await; + let job_id = created["id"].as_str().expect("job id should be a string"); + + let status_response = app + .clone() + .oneshot( + Request::get(format!("/jobs/{job_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(status_response.status(), StatusCode::OK); + + let pause_response = app + .clone() + .oneshot( + Request::post(format!("/jobs/{job_id}/pause")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(pause_response.status(), StatusCode::ACCEPTED); + let paused = poll_job_state(&app, job_id, "PAUSED").await; + assert!(paused["snapshotRevision"].is_number() || paused["snapshotRevision"].is_null()); + + let resume_response = app + .clone() + .oneshot( + Request::post(format!("/jobs/{job_id}/resume")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resume_response.status(), StatusCode::ACCEPTED); + + let cancel_response = app + .clone() + .oneshot( + Request::post(format!("/jobs/{job_id}/cancel")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(cancel_response.status(), StatusCode::ACCEPTED); + + let terminal = loop { + let json = poll_job_state(&app, job_id, "CANCELLED").await; + if json["snapshotRevision"].is_number() { + break json; + } + }; + let snapshot_revision = terminal["snapshotRevision"] + .as_u64() + .expect("snapshot revision should exist"); + + assert_snapshot_analysis_and_routes(&app, job_id, snapshot_revision).await; + let delete_response = app + .clone() + .oneshot( + Request::delete(format!("/jobs/{job_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); +} + +async fn assert_snapshot_analysis_and_routes(app: &axum::Router, job_id: &str, revision: u64) { + let snapshot_response = app + .clone() + .oneshot( + Request::get(format!( + "/jobs/{job_id}/snapshot?snapshot_revision={revision}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(snapshot_response.status(), StatusCode::OK); + + let analysis_response = app + .clone() + .oneshot( + Request::get(format!( + "/jobs/{job_id}/analysis?snapshot_revision={revision}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(analysis_response.status(), StatusCode::OK); + + let routes_response = app + .clone() + .oneshot( + Request::get(format!( + "/jobs/{job_id}/routes?snapshot_revision={revision}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(routes_response.status(), StatusCode::OK); + let routes = read_json(routes_response).await; + assert!(routes["vehicles"] + .as_array() + .expect("routes vehicles should be an array") + .iter() + .all(|vehicle| vehicle["segments"].is_array())); +} diff --git a/tests/api_contract/sse.rs b/tests/api_contract/sse.rs new file mode 100644 index 0000000000000000000000000000000000000000..c1499ceed7b6d024298610caa943fdeaf9aca344 --- /dev/null +++ b/tests/api_contract/sse.rs @@ -0,0 +1,109 @@ +use std::time::Duration; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tokio_stream::StreamExt; +use tower::ServiceExt; + +use crate::support::api::{read_json, small_plan, small_road_network_plan, test_app}; + +#[tokio::test] +async fn sse_endpoint_bootstraps_typed_events() { + let app = test_app(); + + let create_response = app + .clone() + .oneshot( + Request::post("/jobs") + .header("content-type", "application/json") + .body(Body::from(small_plan().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(create_response.status(), StatusCode::OK); + let created = read_json(create_response).await; + let job_id = created["id"].as_str().expect("job id should be present"); + + let events_response = app + .clone() + .oneshot( + Request::get(format!("/jobs/{job_id}/events")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(events_response.status(), StatusCode::OK); + let mut stream = events_response.into_body().into_data_stream(); + let first_chunk = stream + .next() + .await + .expect("sse stream should yield a bootstrap chunk") + .expect("bootstrap chunk should be valid"); + let text = String::from_utf8(first_chunk.to_vec()).expect("chunk should be UTF-8"); + assert!(text.contains("\"eventType\"")); + assert!(text.contains("\"lifecycleState\"")); + + let _ = app + .clone() + .oneshot( + Request::post(format!("/jobs/{job_id}/cancel")) + .body(Body::empty()) + .unwrap(), + ) + .await; +} + +#[tokio::test] +async fn road_network_job_routes_work_when_live_tests_are_enabled() { + if std::env::var("SOLVERFORGE_RUN_LIVE_TESTS").ok().as_deref() != Some("1") { + return; + } + + let app = test_app(); + let create_response = app + .clone() + .oneshot( + Request::post("/jobs") + .header("content-type", "application/json") + .body(Body::from(small_road_network_plan().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(create_response.status(), StatusCode::OK); + let created = read_json(create_response).await; + let job_id = created["id"].as_str().unwrap(); + + let snapshot_response = loop { + let response = app + .clone() + .oneshot( + Request::get(format!("/jobs/{job_id}/snapshot")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + if response.status() == StatusCode::OK { + break response; + } + tokio::time::sleep(Duration::from_millis(150)).await; + }; + let snapshot = read_json(snapshot_response).await; + let revision = snapshot["snapshotRevision"].as_u64().unwrap(); + + let routes_response = app + .clone() + .oneshot( + Request::get(format!( + "/jobs/{job_id}/routes?snapshot_revision={revision}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(routes_response.status(), StatusCode::OK); +} diff --git a/tests/e2e/app.spec.js b/tests/e2e/app.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ac1fb1c95c099eb77b37243aa53f240492c75fab --- /dev/null +++ b/tests/e2e/app.spec.js @@ -0,0 +1,128 @@ +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; +} + +function demoSelect(page) { + return page.locator('.deliveries-field').filter({ hasText: 'Demo Data' }).locator('select'); +} + +function routingSelect(page) { + return page.locator('.deliveries-field').filter({ hasText: 'Routing Mode' }).locator('select'); +} + +test('boots the real deliveries app and serves required browser assets', async ({ page, request }) => { + const errors = collectBrowserErrors(page); + + await expect(request.get('/health')).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('/sf-config.json')).resolves.toBeOK(); + + await page.goto('/'); + await expect(page).toHaveTitle('SolverForge Deliveries'); + await expect(page.getByText('Retained delivery-route optimization with route previews')).toBeVisible(); + await expect(page.locator('#sfStatusText')).toHaveText('Ready'); + await expect(page.locator('.sf-constraint-dot')).toHaveCount(4); + + for (const tab of ['Overview', 'By Vehicle', 'By Delivery', 'Data', 'REST API']) { + await expect(page.getByRole('tab', { name: tab })).toBeVisible(); + } + + await expect(demoSelect(page)).toHaveValue('PHILADELPHIA'); + await expect(routingSelect(page)).toHaveValue('road_network'); + await expect(page.getByText('PHILADELPHIA · road network · 82 deliveries · 10 vehicles')).toBeVisible(); + await expect(page.locator('.deliveries-list__row')).toHaveCount(10); + await expect(page.locator('#deliveries-map')).toBeVisible(); + await expect(page.locator('.sf-marker-vehicle')).toHaveCount(10); + await expect(page.locator('.sf-marker-visit')).toHaveCount(82); + + expect(errors).toEqual([]); +}); + +test('switches datasets and exposes delivery-specific app panels', async ({ page }) => { + const errors = collectBrowserErrors(page); + + await page.goto('/'); + await demoSelect(page).selectOption('HARTFORD'); + await expect(page.getByText('HARTFORD · road network · 50 deliveries · 10 vehicles')).toBeVisible(); + await expect(page.locator('.sf-marker-visit')).toHaveCount(50); + + await demoSelect(page).selectOption('FIRENZE'); + await expect(page.getByText('FIRENZE · road network · 80 deliveries · 10 vehicles')).toBeVisible(); + await expect(page.locator('.sf-marker-visit')).toHaveCount(80); + + await page.getByRole('tab', { name: 'Data' }).click(); + await expect(page.getByRole('heading', { name: 'Draft Data' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Vehicles' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Deliveries' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Recommend' }).first()).toBeVisible(); + await expect(page.locator('button').filter({ hasText: /Add|Remove/ })).toHaveCount(0); + + await page.getByRole('tab', { name: 'REST API' }).click(); + await expect(page.getByRole('heading', { name: 'GET /jobs/{id}/routes?snapshot_revision={n}' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'POST /recommendations/delivery-insertions' })).toBeVisible(); + + expect(errors).toEqual([]); +}); + +test('highlights a route without moving or zooming the map', async ({ page }) => { + const errors = collectBrowserErrors(page); + + await page.goto('/'); + await expect(page.locator('.sf-marker-visit')).toHaveCount(82); + await page.evaluate(() => { + window.__fitBoundsCalls = 0; + const originalFitBounds = window.L.Map.prototype.fitBounds; + window.L.Map.prototype.fitBounds = function (...args) { + window.__fitBoundsCalls += 1; + return originalFitBounds.apply(this, args); + }; + }); + + const firstRoute = page.locator('.deliveries-list__row').first(); + await firstRoute.click(); + + await expect(firstRoute).toHaveClass(/is-focused/); + await expect(firstRoute.getByRole('button')).toHaveText('Show All'); + await expect.poll(() => page.evaluate(() => window.__fitBoundsCalls)).toBe(0); + + expect(errors).toEqual([]); +}); + +test('starts a retained straight-line solve and returns control to the user', async ({ page }) => { + const errors = collectBrowserErrors(page); + + await page.goto('/'); + await routingSelect(page).selectOption('straight_line'); + await page.locator('button').filter({ hasText: 'Solve' }).first().click(); + + await expect(page.locator('#sf-app')).toHaveAttribute('data-job-id', /.+/, { timeout: 10_000 }); + const stopButton = page.locator('button').filter({ hasText: 'Stop' }).first(); + if (await stopButton.isVisible()) { + await stopButton.click(); + } + await expect(page.locator('button').filter({ hasText: 'Solve' }).first()).toBeVisible({ timeout: 15_000 }); + + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 0000000000000000000000000000000000000000..48e29926df1a1fe9df6cdeed13b751ae2d426d1a --- /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 || 17960); +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_deliveries')}`, + cwd: rootDir, + url: `${baseURL}/health`, + timeout: 20_000, + reuseExistingServer: false, + }, +}; diff --git a/tests/frontend_models.test.mjs b/tests/frontend_models.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..b499bed03f0aa2c4c4a75f3505e42a5e566d5cd3 --- /dev/null +++ b/tests/frontend_models.test.mjs @@ -0,0 +1,142 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildDeliveryTimelineModel, + buildVehicleTimelineModel, + refreshPlan, + refreshServerPlan, +} from '../static/app/models.mjs'; +import { + colorForVehicle, + routeStyleForVehicle, +} from '../static/app/ui/overview.mjs'; + +function basePlan() { + return { + name: 'Test City', + routingMode: 'straight_line', + deliveries: [ + { + id: 0, + label: 'A', + kind: 'business', + lat: 43.7696, + lng: 11.2558, + demand: 3, + minStartTime: 8 * 3600, + maxEndTime: 12 * 3600, + serviceDuration: 10 * 60, + }, + { + id: 1, + label: 'B', + kind: 'residential', + lat: 43.7710, + lng: 11.2620, + demand: 2, + minStartTime: 9 * 3600, + maxEndTime: 18 * 3600, + serviceDuration: 5 * 60, + }, + ], + vehicles: [ + { + id: 0, + name: 'Alpha', + capacity: 10, + homeLat: 43.7696, + homeLng: 11.2558, + departureTime: 6 * 3600, + deliveryOrder: [0, 1], + }, + ], + viewState: {}, + }; +} + +test('refreshPlan builds preview assignments and scores', () => { + const plan = refreshPlan(basePlan()); + assert.equal(plan.viewState.preview.vehicles.length, 1); + assert.equal(plan.viewState.preview.deliveries[0].assignedVehicleName, 'Alpha'); + assert.equal(plan.viewState.preview.unassignedDeliveryIds.length, 0); +}); + +test('refreshServerPlan preserves authoritative backend preview', () => { + const plan = basePlan(); + plan.viewState.preview = { + hardScore: -99, + softScore: -12345, + unassignedDeliveryIds: [1], + vehicles: [], + deliveries: [], + }; + + const refreshed = refreshServerPlan(plan); + + assert.equal(refreshed.viewState.preview.hardScore, -99); + assert.equal(refreshed.viewState.preview.softScore, -12345); + assert.deepEqual(refreshed.viewState.preview.unassignedDeliveryIds, [1]); +}); + +test('refreshPlan recomputes draft preview from straight-line estimates', () => { + const plan = basePlan(); + plan.viewState.preview = { + hardScore: -99, + softScore: -12345, + unassignedDeliveryIds: [1], + vehicles: [], + deliveries: [], + }; + + const refreshed = refreshPlan(plan); + + assert.equal(Math.abs(refreshed.viewState.preview.hardScore), 0); + assert.notEqual(refreshed.viewState.preview.softScore, -12345); + assert.equal(refreshed.viewState.preview.vehicles.length, 1); + assert.ok(refreshed.viewState.preview.vehicles[0].totalTravelSeconds > 0); +}); + +test('refreshPlan uses dominant unassigned-delivery hard penalty', () => { + const plan = basePlan(); + plan.vehicles[0].deliveryOrder = [0]; + + const refreshed = refreshPlan(plan); + + assert.equal(refreshed.viewState.preview.unassignedDeliveryIds.length, 1); + assert.ok( + refreshed.viewState.preview.hardScore <= -1_000_000, + `expected unassigned hard penalty to dominate, got ${refreshed.viewState.preview.hardScore}`, + ); +}); + +test('timeline model builders emit lane collections', () => { + const preview = refreshPlan(basePlan()).viewState.preview; + const vehicleModel = buildVehicleTimelineModel(preview); + const deliveryModel = buildDeliveryTimelineModel(preview); + + assert.equal(vehicleModel.lanes.length, 1); + assert.equal(vehicleModel.lanes[0].items.length, 2); + assert.equal(deliveryModel.lanes.length, 2); + assert.ok(deliveryModel.lanes[0].items.length >= 1); +}); + +test('vehicle map colors are unique across the ten-vehicle tutorial dataset', () => { + const colors = Array.from({ length: 10 }, (_, vehicleId) => colorForVehicle(vehicleId)); + + assert.equal(new Set(colors).size, 10); + assert.notEqual(colorForVehicle(1), colorForVehicle(9)); +}); + +test('route highlight style is keyed by vehicle id, not color reuse', () => { + assert.deepEqual(routeStyleForVehicle(1, 1), { + color: colorForVehicle(1), + opacity: 1, + weight: 5, + }); + assert.deepEqual(routeStyleForVehicle(9, 1), { + color: colorForVehicle(9), + opacity: 0.2, + weight: 2, + }); +}); diff --git a/tests/support/api.rs b/tests/support/api.rs new file mode 100644 index 0000000000000000000000000000000000000000..17895f026aa0aabb50f0de3a15ff6f4ccebceb24 --- /dev/null +++ b/tests/support/api.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use solverforge_deliveries::api; +use solverforge_deliveries::data::{generate, DemoData}; +use solverforge_deliveries::domain::{Plan, RoutingMode}; +use tower::ServiceExt; +use tower_http::services::ServeDir; + +pub fn test_app() -> axum::Router { + let state = Arc::new(api::AppState::new()); + api::router(state) + .merge(solverforge_ui::routes()) + .fallback_service(ServeDir::new("static")) +} + +pub fn small_plan() -> serde_json::Value { + let mut plan = generate(DemoData::Hartford); + plan.deliveries.truncate(8); + plan.vehicles.truncate(3); + plan.normalize(); + plan.routing_mode = RoutingMode::StraightLine; + serde_json::to_value(plan.refreshed_for_transport()).expect("plan should serialize") +} + +pub fn bigger_plan() -> serde_json::Value { + let mut plan = generate(DemoData::Philadelphia); + plan.routing_mode = RoutingMode::StraightLine; + serde_json::to_value(plan.refreshed_for_transport()).expect("plan should serialize") +} + +pub fn completion_plan() -> serde_json::Value { + let mut plan = generate(DemoData::Hartford); + plan.deliveries.truncate(6); + plan.vehicles.truncate(2); + plan.normalize(); + plan.routing_mode = RoutingMode::StraightLine; + serde_json::to_value(plan.refreshed_for_transport()).expect("plan should serialize") +} + +pub fn empty_road_network_plan() -> serde_json::Value { + let mut plan = Plan::new("Empty Road Network", Vec::new(), Vec::new()); + plan.routing_mode = RoutingMode::RoadNetwork; + serde_json::to_value(plan.refreshed_for_transport()).expect("plan should serialize") +} + +pub fn small_road_network_plan() -> serde_json::Value { + let mut plan = generate(DemoData::Hartford); + plan.deliveries.truncate(4); + plan.vehicles.truncate(2); + plan.normalize(); + plan.routing_mode = RoutingMode::RoadNetwork; + serde_json::to_value(plan.refreshed_for_transport()).expect("plan should serialize") +} + +pub async fn read_json(response: axum::response::Response) -> serde_json::Value { + let body = read_body_text(response).await; + serde_json::from_str(&body).expect("body should be valid JSON") +} + +pub async fn read_body_text(response: axum::response::Response) -> String { + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + String::from_utf8(body.to_vec()).expect("body should be valid UTF-8") +} + +pub fn assert_score_value<'a>(value: &'a serde_json::Value, label: &str) -> &'a str { + let score = value + .as_str() + .unwrap_or_else(|| panic!("{label} should be a display string, got {value:?}")); + assert_score_text(score, label); + score +} + +pub fn assert_score_text(score: &str, label: &str) { + assert!( + score.contains("hard/") && score.ends_with("soft") && !score.trim_start().starts_with('{'), + "{label} should use SolverForge display-score format, got {score:?}" + ); +} + +pub async fn poll_job_state(app: &axum::Router, job_id: &str, wanted: &str) -> serde_json::Value { + let start = Instant::now(); + loop { + let response = app + .clone() + .oneshot( + Request::get(format!("/jobs/{job_id}")) + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + if json["lifecycleState"] == wanted { + return json; + } + if start.elapsed() > Duration::from_secs(6) { + panic!("job {job_id} did not reach {wanted} in time; last state={json:?}"); + } + tokio::time::sleep(Duration::from_millis(80)).await; + } +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..e5fdf85eed770ee8007ec44a632f67913428a9c9 --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1 @@ +pub mod api;