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

chore: sync uc-fsr Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +9 -0
  2. .gitattributes +36 -0
  3. .gitignore +8 -0
  4. .pre-commit-config.yaml +21 -0
  5. AGENTS.md +93 -0
  6. CHANGELOG.md +69 -0
  7. Cargo.lock +2683 -0
  8. Cargo.toml +30 -0
  9. Dockerfile +36 -0
  10. Makefile +207 -0
  11. README.md +213 -0
  12. WIREFRAME.md +207 -0
  13. docs/screenshot.png +3 -0
  14. solver.toml +48 -0
  15. solverforge.app.toml +100 -0
  16. src/api/dto.rs +332 -0
  17. src/api/mod.rs +13 -0
  18. src/api/route_dto.rs +57 -0
  19. src/api/route_geometry.rs +286 -0
  20. src/api/routes.rs +251 -0
  21. src/api/sse.rs +71 -0
  22. src/constraints/assigned_visits.rs +277 -0
  23. src/constraints/balance_workload.rs +12 -0
  24. src/constraints/minimize_travel.rs +12 -0
  25. src/constraints/mod.rs +48 -0
  26. src/constraints/priority_slack.rs +12 -0
  27. src/constraints/reachable_legs.rs +14 -0
  28. src/constraints/required_parts.rs +14 -0
  29. src/constraints/required_skills.rs +14 -0
  30. src/constraints/route_metrics_tests.rs +171 -0
  31. src/constraints/shift_capacity.rs +14 -0
  32. src/constraints/territory_affinity.rs +12 -0
  33. src/constraints/time_windows.rs +14 -0
  34. src/data/bergamo_catalog.rs +60 -0
  35. src/data/bergamo_locations.rs +194 -0
  36. src/data/bergamo_profiles.rs +61 -0
  37. src/data/bergamo_technicians.rs +70 -0
  38. src/data/data_seed.rs +293 -0
  39. src/data/mod.rs +15 -0
  40. src/domain/field_service_plan.rs +127 -0
  41. src/domain/location.rs +73 -0
  42. src/domain/mod.rs +29 -0
  43. src/domain/route_metrics.rs +194 -0
  44. src/domain/service_visit.rs +98 -0
  45. src/domain/technician_route.rs +220 -0
  46. src/domain/travel_leg.rs +76 -0
  47. src/lib.rs +12 -0
  48. src/main.rs +73 -0
  49. src/solver/event_payload.rs +205 -0
  50. src/solver/mod.rs +10 -0
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ target/
2
+ .git/
3
+ .osm_cache/
4
+ test-results/
5
+ playwright-report/
6
+ *.rs.bk
7
+ /app-session-*.js
8
+ /comment-preload.js
9
+ /main-*.js
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ docs/screenshot.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /target
2
+ .osm_cache/
3
+ **/*.rs.bk
4
+ test-results/
5
+ playwright-report/
6
+ /app-session-*.js
7
+ /comment-preload.js
8
+ /main-*.js
.pre-commit-config.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v4.5.0
4
+ hooks:
5
+ - id: check-yaml
6
+ - id: end-of-file-fixer
7
+ - id: trailing-whitespace
8
+ - id: check-merge-conflict
9
+ - id: check-added-large-files
10
+
11
+ - repo: https://github.com/gitleaks/gitleaks
12
+ rev: v8.18.0
13
+ hooks:
14
+ - id: gitleaks
15
+ - repo: https://github.com/doublify/pre-commit-rust
16
+ rev: v1.0
17
+ hooks:
18
+ - id: fmt
19
+ args: ["--", "--check"]
20
+ - id: clippy
21
+ args: ["--", "-D", "warnings"]
AGENTS.md ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Repository Guidelines
2
+
3
+ ## Project Structure And Naming
4
+
5
+ `solverforge-fsr` is a Rust 1.95 SolverForge field-service routing app with an
6
+ Axum server and static browser workspace. The app package version is declared
7
+ in `Cargo.toml`, and the release binary is `solverforge_fsr`.
8
+
9
+ - `src/domain/mod.rs` owns the `solverforge::planning_model!` manifest.
10
+ - `src/domain/field_service_plan.rs` owns the `FieldServicePlan` solution,
11
+ transient visit-index normalization, and route shadow refresh hook.
12
+ - `src/domain/location.rs`, `service_visit.rs`, and `travel_leg.rs` own the
13
+ problem facts.
14
+ - `src/domain/technician_route.rs` owns the planning entity and its `visits`
15
+ list variable.
16
+ - `src/domain/route_metrics.rs` owns route shadow measurement.
17
+ - `src/constraints/` owns SolverForge scoring rules, one business rule per file.
18
+ Prefer stock `ConstraintFactory` streams; `assigned_visits.rs` keeps the
19
+ duplicate-assignment check as a small custom `IncrementalConstraint` because
20
+ a grouped stream would count singleton groups as analysis matches.
21
+ - `src/data/data_seed.rs` owns `STANDARD` demo assembly and road-matrix
22
+ preparation; `src/data/bergamo_*.rs` owns the static locations, visit
23
+ profiles, technicians, and shared catalog types.
24
+ - `src/api/` owns REST, DTO, route geometry, and SSE surfaces.
25
+ - `src/solver/` owns retained-job runtime orchestration.
26
+ - `static/` owns the browser workspace, split by responsibility
27
+ (`app-route-state.js`, `app-render-routes.js`, etc.).
28
+ - `Dockerfile`, `Makefile`, `solver.toml`, and `solverforge.app.toml` define
29
+ the deployment and runtime contract.
30
+
31
+ Keep handwritten source, docs, and deployment files under 300 lines; split by
32
+ module or responsibility when a file approaches that size.
33
+
34
+ ## Build, Test, and Development Commands
35
+
36
+ - `make doctor` checks local `cargo`, `rustc`, `node`, and `docker` readiness.
37
+ - `make run` runs the debug server on `PORT` (default `7860`).
38
+ - `make build-release` builds `solverforge_fsr` in release mode.
39
+ - `make test` runs Rust tests, frontend JavaScript syntax checks, and the
40
+ Playwright browser smoke.
41
+ - `make lint` runs `cargo fmt --check`, clippy with warnings denied, and JS syntax checks.
42
+ - `make ci-local` runs the full Hugging Face Space validation path, including Docker image build.
43
+ - `make space-run` builds and runs the Docker Space image locally.
44
+
45
+ ## Coding Style & Naming Conventions
46
+
47
+ Use idiomatic Rust 2021 with `cargo fmt` formatting and clippy under
48
+ `-D warnings`. Rust modules and files use `snake_case`; types use `PascalCase`;
49
+ functions, fields, and variables use `snake_case`. Keep API DTOs explicit and
50
+ snapshot-scoped. Frontend files should stay plain JavaScript modules with clear
51
+ ownership boundaries rather than large shared scripts.
52
+
53
+ ## Testing Guidelines
54
+
55
+ Place Rust unit tests near the code they cover, using descriptive names such as
56
+ `reports_unreachable_route_segments`. Run `make test` before handing off normal
57
+ changes and `make ci-local` before deployment, dependency, Docker, or Space
58
+ changes. Frontend validation includes `node --check` over `static/*.js`; served
59
+ browser behavior is covered by `make test-e2e`.
60
+
61
+ ## Documentation And Commenting Policy
62
+
63
+ Assume a reader who is new to Rust and new to planning optimization.
64
+
65
+ - Keep `README.md`, `WIREFRAME.md`, this file, `solver.toml`,
66
+ `solverforge.app.toml`, `static/sf-config.json`, and the visible browser API
67
+ guide aligned.
68
+ - Keep `docs/screenshot.png` current whenever the visible browser shell changes.
69
+ - Add module or function comments where code coordinates SolverForge concepts:
70
+ facts, planning entities, variables, retained jobs, road matrices, route
71
+ geometry, or score math.
72
+ - Explain domain meaning and solver consequences. Do not keep scaffold
73
+ placeholders, future-tense planning prose, or comments that merely restate
74
+ syntax.
75
+ - When docs mention versions, counts, routes, demo IDs, solver policy, or
76
+ validation expectations, verify those facts against current code in the same
77
+ patch.
78
+
79
+ ## Commit & Pull Request Guidelines
80
+
81
+ History uses conventional commits such as `feat(fsr): ...`, `fix(ui): ...`,
82
+ and `chore: ...`. Keep each commit focused on one revertable
83
+ intent and include a full body when the change spans behavior, deployment, or
84
+ dependencies. PRs should describe the user-visible effect, linked issue or
85
+ review comment, validation commands run, and include screenshots for visible UI
86
+ changes.
87
+
88
+ ## Security & Configuration Tips
89
+
90
+ Do not commit credentials, local Hugging Face tokens, generated desktop bundles,
91
+ or build output. Keep Docker/Space builds registry-backed through the declared
92
+ crates.io dependency line unless the build context explicitly vendors local
93
+ crates.
CHANGELOG.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this use case are documented in this file.
4
+
5
+ ## 2.0.7 (2026-07-29)
6
+
7
+ ### Maintenance
8
+
9
+ * **release:** target SolverForge 0.19.3 and solverforge-core 0.19.3.
10
+
11
+ ## 2.0.6 (2026-07-17)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **fsr:** target SolverForge 0.19.0 da3db25
17
+
18
+ ## 2.0.5 (2026-07-13)
19
+
20
+ ### Maintenance
21
+
22
+ * **release:** target SolverForge 0.18.0 and solverforge-core 0.18.0.
23
+ * **docs:** align priority-slack semantics and the source map with the current app.
24
+ * **metadata:** use the canonical uppercase `STANDARD` demo id.
25
+
26
+ ## 2.0.4 (2026-06-16)
27
+
28
+ ### Maintenance
29
+
30
+ * **release:** target SolverForge 0.17.1, solverforge-core 0.17.1, and solverforge-cli 2.2.2.
31
+ * **metadata:** align generated list-variable metadata with the current SolverForge UI schema.
32
+
33
+ ## 2.0.3 (2026-05-28)
34
+
35
+ ### Maintenance
36
+
37
+ * **release:** target SolverForge 0.15.0 and solverforge-core 0.15.0.
38
+
39
+ ## 2.0.2 (2026-05-28)
40
+
41
+ ### Fixes
42
+
43
+ * **scoring:** rebuild transient visit indexes after JSON round trips and enforce exactly-once service visit assignment.
44
+
45
+ ### Maintenance
46
+
47
+ * **docs:** align FSR architecture notes and use-case wireframes with the current retained runtime and API surface.
48
+
49
+ ## 2.0.1 (2026-05-16)
50
+
51
+ ### Maintenance
52
+
53
+ * **release:** target SolverForge 0.14.1 and solverforge-core 0.14.1.
54
+
55
+ ## 2.0.0 (2026-05-14)
56
+
57
+ ### Maintenance
58
+
59
+ * **release:** set the public app release line to 2.0.0 across Cargo metadata and release validation.
60
+
61
+ ## 1.0.1 (2026-05-14)
62
+
63
+ ### Features
64
+
65
+ * **fsr:** publish the SolverForge field-service routing use case in the bundle.
66
+
67
+ ### Maintenance
68
+
69
+ * **release:** align the bundled app with SolverForge 0.13.1, solverforge-core 0.13.1, solverforge-ui 0.6.5, and solverforge-maps 2.1.4.
Cargo.lock ADDED
@@ -0,0 +1,2683 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "aho-corasick"
7
+ version = "1.1.4"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10
+ dependencies = [
11
+ "memchr",
12
+ ]
13
+
14
+ [[package]]
15
+ name = "anyhow"
16
+ version = "1.0.102"
17
+ source = "registry+https://github.com/rust-lang/crates.io-index"
18
+ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
19
+
20
+ [[package]]
21
+ name = "arrayvec"
22
+ version = "0.7.6"
23
+ source = "registry+https://github.com/rust-lang/crates.io-index"
24
+ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
25
+
26
+ [[package]]
27
+ name = "atomic-waker"
28
+ version = "1.1.2"
29
+ source = "registry+https://github.com/rust-lang/crates.io-index"
30
+ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
31
+
32
+ [[package]]
33
+ name = "aws-lc-rs"
34
+ version = "1.17.0"
35
+ source = "registry+https://github.com/rust-lang/crates.io-index"
36
+ checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
37
+ dependencies = [
38
+ "aws-lc-sys",
39
+ "zeroize",
40
+ ]
41
+
42
+ [[package]]
43
+ name = "aws-lc-sys"
44
+ version = "0.41.0"
45
+ source = "registry+https://github.com/rust-lang/crates.io-index"
46
+ checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
47
+ dependencies = [
48
+ "cc",
49
+ "cmake",
50
+ "dunce",
51
+ "fs_extra",
52
+ ]
53
+
54
+ [[package]]
55
+ name = "axum"
56
+ version = "0.8.9"
57
+ source = "registry+https://github.com/rust-lang/crates.io-index"
58
+ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
59
+ dependencies = [
60
+ "axum-core",
61
+ "bytes",
62
+ "form_urlencoded",
63
+ "futures-util",
64
+ "http",
65
+ "http-body",
66
+ "http-body-util",
67
+ "hyper",
68
+ "hyper-util",
69
+ "itoa",
70
+ "matchit",
71
+ "memchr",
72
+ "mime",
73
+ "percent-encoding",
74
+ "pin-project-lite",
75
+ "serde_core",
76
+ "serde_json",
77
+ "serde_path_to_error",
78
+ "serde_urlencoded",
79
+ "sync_wrapper",
80
+ "tokio",
81
+ "tower",
82
+ "tower-layer",
83
+ "tower-service",
84
+ "tracing",
85
+ ]
86
+
87
+ [[package]]
88
+ name = "axum-core"
89
+ version = "0.5.6"
90
+ source = "registry+https://github.com/rust-lang/crates.io-index"
91
+ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
92
+ dependencies = [
93
+ "bytes",
94
+ "futures-core",
95
+ "http",
96
+ "http-body",
97
+ "http-body-util",
98
+ "mime",
99
+ "pin-project-lite",
100
+ "sync_wrapper",
101
+ "tower-layer",
102
+ "tower-service",
103
+ "tracing",
104
+ ]
105
+
106
+ [[package]]
107
+ name = "base64"
108
+ version = "0.22.1"
109
+ source = "registry+https://github.com/rust-lang/crates.io-index"
110
+ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
111
+
112
+ [[package]]
113
+ name = "bitflags"
114
+ version = "2.13.0"
115
+ source = "registry+https://github.com/rust-lang/crates.io-index"
116
+ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
117
+
118
+ [[package]]
119
+ name = "bumpalo"
120
+ version = "3.20.3"
121
+ source = "registry+https://github.com/rust-lang/crates.io-index"
122
+ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
123
+
124
+ [[package]]
125
+ name = "bytes"
126
+ version = "1.11.1"
127
+ source = "registry+https://github.com/rust-lang/crates.io-index"
128
+ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
129
+
130
+ [[package]]
131
+ name = "cc"
132
+ version = "1.2.64"
133
+ source = "registry+https://github.com/rust-lang/crates.io-index"
134
+ checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
135
+ dependencies = [
136
+ "find-msvc-tools",
137
+ "jobserver",
138
+ "libc",
139
+ "shlex",
140
+ ]
141
+
142
+ [[package]]
143
+ name = "cfg-if"
144
+ version = "1.0.4"
145
+ source = "registry+https://github.com/rust-lang/crates.io-index"
146
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
147
+
148
+ [[package]]
149
+ name = "cfg_aliases"
150
+ version = "0.2.1"
151
+ source = "registry+https://github.com/rust-lang/crates.io-index"
152
+ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
153
+
154
+ [[package]]
155
+ name = "chacha20"
156
+ version = "0.10.0"
157
+ source = "registry+https://github.com/rust-lang/crates.io-index"
158
+ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
159
+ dependencies = [
160
+ "cfg-if",
161
+ "cpufeatures",
162
+ "rand_core 0.10.1",
163
+ ]
164
+
165
+ [[package]]
166
+ name = "cmake"
167
+ version = "0.1.58"
168
+ source = "registry+https://github.com/rust-lang/crates.io-index"
169
+ checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
170
+ dependencies = [
171
+ "cc",
172
+ ]
173
+
174
+ [[package]]
175
+ name = "combine"
176
+ version = "4.6.7"
177
+ source = "registry+https://github.com/rust-lang/crates.io-index"
178
+ checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
179
+ dependencies = [
180
+ "bytes",
181
+ "memchr",
182
+ ]
183
+
184
+ [[package]]
185
+ name = "core-foundation"
186
+ version = "0.9.4"
187
+ source = "registry+https://github.com/rust-lang/crates.io-index"
188
+ checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
189
+ dependencies = [
190
+ "core-foundation-sys",
191
+ "libc",
192
+ ]
193
+
194
+ [[package]]
195
+ name = "core-foundation"
196
+ version = "0.10.1"
197
+ source = "registry+https://github.com/rust-lang/crates.io-index"
198
+ checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
199
+ dependencies = [
200
+ "core-foundation-sys",
201
+ "libc",
202
+ ]
203
+
204
+ [[package]]
205
+ name = "core-foundation-sys"
206
+ version = "0.8.7"
207
+ source = "registry+https://github.com/rust-lang/crates.io-index"
208
+ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
209
+
210
+ [[package]]
211
+ name = "cpufeatures"
212
+ version = "0.3.0"
213
+ source = "registry+https://github.com/rust-lang/crates.io-index"
214
+ checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
215
+ dependencies = [
216
+ "libc",
217
+ ]
218
+
219
+ [[package]]
220
+ name = "crossbeam-deque"
221
+ version = "0.8.6"
222
+ source = "registry+https://github.com/rust-lang/crates.io-index"
223
+ checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
224
+ dependencies = [
225
+ "crossbeam-epoch",
226
+ "crossbeam-utils",
227
+ ]
228
+
229
+ [[package]]
230
+ name = "crossbeam-epoch"
231
+ version = "0.9.18"
232
+ source = "registry+https://github.com/rust-lang/crates.io-index"
233
+ checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
234
+ dependencies = [
235
+ "crossbeam-utils",
236
+ ]
237
+
238
+ [[package]]
239
+ name = "crossbeam-utils"
240
+ version = "0.8.21"
241
+ source = "registry+https://github.com/rust-lang/crates.io-index"
242
+ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
243
+
244
+ [[package]]
245
+ name = "displaydoc"
246
+ version = "0.2.6"
247
+ source = "registry+https://github.com/rust-lang/crates.io-index"
248
+ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
249
+ dependencies = [
250
+ "proc-macro2",
251
+ "quote",
252
+ "syn",
253
+ ]
254
+
255
+ [[package]]
256
+ name = "dunce"
257
+ version = "1.0.5"
258
+ source = "registry+https://github.com/rust-lang/crates.io-index"
259
+ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
260
+
261
+ [[package]]
262
+ name = "either"
263
+ version = "1.16.0"
264
+ source = "registry+https://github.com/rust-lang/crates.io-index"
265
+ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
266
+
267
+ [[package]]
268
+ name = "encoding_rs"
269
+ version = "0.8.35"
270
+ source = "registry+https://github.com/rust-lang/crates.io-index"
271
+ checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
272
+ dependencies = [
273
+ "cfg-if",
274
+ ]
275
+
276
+ [[package]]
277
+ name = "equivalent"
278
+ version = "1.0.2"
279
+ source = "registry+https://github.com/rust-lang/crates.io-index"
280
+ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
281
+
282
+ [[package]]
283
+ name = "errno"
284
+ version = "0.3.14"
285
+ source = "registry+https://github.com/rust-lang/crates.io-index"
286
+ checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
287
+ dependencies = [
288
+ "libc",
289
+ "windows-sys 0.61.2",
290
+ ]
291
+
292
+ [[package]]
293
+ name = "find-msvc-tools"
294
+ version = "0.1.9"
295
+ source = "registry+https://github.com/rust-lang/crates.io-index"
296
+ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
297
+
298
+ [[package]]
299
+ name = "fnv"
300
+ version = "1.0.7"
301
+ source = "registry+https://github.com/rust-lang/crates.io-index"
302
+ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
303
+
304
+ [[package]]
305
+ name = "foldhash"
306
+ version = "0.1.5"
307
+ source = "registry+https://github.com/rust-lang/crates.io-index"
308
+ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
309
+
310
+ [[package]]
311
+ name = "form_urlencoded"
312
+ version = "1.2.2"
313
+ source = "registry+https://github.com/rust-lang/crates.io-index"
314
+ checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
315
+ dependencies = [
316
+ "percent-encoding",
317
+ ]
318
+
319
+ [[package]]
320
+ name = "fs_extra"
321
+ version = "1.3.0"
322
+ source = "registry+https://github.com/rust-lang/crates.io-index"
323
+ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
324
+
325
+ [[package]]
326
+ name = "futures-channel"
327
+ version = "0.3.32"
328
+ source = "registry+https://github.com/rust-lang/crates.io-index"
329
+ checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
330
+ dependencies = [
331
+ "futures-core",
332
+ ]
333
+
334
+ [[package]]
335
+ name = "futures-core"
336
+ version = "0.3.32"
337
+ source = "registry+https://github.com/rust-lang/crates.io-index"
338
+ checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
339
+
340
+ [[package]]
341
+ name = "futures-sink"
342
+ version = "0.3.32"
343
+ source = "registry+https://github.com/rust-lang/crates.io-index"
344
+ checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
345
+
346
+ [[package]]
347
+ name = "futures-task"
348
+ version = "0.3.32"
349
+ source = "registry+https://github.com/rust-lang/crates.io-index"
350
+ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
351
+
352
+ [[package]]
353
+ name = "futures-util"
354
+ version = "0.3.32"
355
+ source = "registry+https://github.com/rust-lang/crates.io-index"
356
+ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
357
+ dependencies = [
358
+ "futures-core",
359
+ "futures-task",
360
+ "pin-project-lite",
361
+ "slab",
362
+ ]
363
+
364
+ [[package]]
365
+ name = "getrandom"
366
+ version = "0.2.17"
367
+ source = "registry+https://github.com/rust-lang/crates.io-index"
368
+ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
369
+ dependencies = [
370
+ "cfg-if",
371
+ "js-sys",
372
+ "libc",
373
+ "wasi",
374
+ "wasm-bindgen",
375
+ ]
376
+
377
+ [[package]]
378
+ name = "getrandom"
379
+ version = "0.3.4"
380
+ source = "registry+https://github.com/rust-lang/crates.io-index"
381
+ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
382
+ dependencies = [
383
+ "cfg-if",
384
+ "js-sys",
385
+ "libc",
386
+ "r-efi 5.3.0",
387
+ "wasip2",
388
+ "wasm-bindgen",
389
+ ]
390
+
391
+ [[package]]
392
+ name = "getrandom"
393
+ version = "0.4.2"
394
+ source = "registry+https://github.com/rust-lang/crates.io-index"
395
+ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
396
+ dependencies = [
397
+ "cfg-if",
398
+ "libc",
399
+ "r-efi 6.0.0",
400
+ "rand_core 0.10.1",
401
+ "wasip2",
402
+ "wasip3",
403
+ ]
404
+
405
+ [[package]]
406
+ name = "h2"
407
+ version = "0.4.14"
408
+ source = "registry+https://github.com/rust-lang/crates.io-index"
409
+ checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
410
+ dependencies = [
411
+ "atomic-waker",
412
+ "bytes",
413
+ "fnv",
414
+ "futures-core",
415
+ "futures-sink",
416
+ "http",
417
+ "indexmap",
418
+ "slab",
419
+ "tokio",
420
+ "tokio-util",
421
+ "tracing",
422
+ ]
423
+
424
+ [[package]]
425
+ name = "hashbrown"
426
+ version = "0.15.5"
427
+ source = "registry+https://github.com/rust-lang/crates.io-index"
428
+ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
429
+ dependencies = [
430
+ "foldhash",
431
+ ]
432
+
433
+ [[package]]
434
+ name = "hashbrown"
435
+ version = "0.17.1"
436
+ source = "registry+https://github.com/rust-lang/crates.io-index"
437
+ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
438
+
439
+ [[package]]
440
+ name = "heck"
441
+ version = "0.5.0"
442
+ source = "registry+https://github.com/rust-lang/crates.io-index"
443
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
444
+
445
+ [[package]]
446
+ name = "http"
447
+ version = "1.4.2"
448
+ source = "registry+https://github.com/rust-lang/crates.io-index"
449
+ checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
450
+ dependencies = [
451
+ "bytes",
452
+ "itoa",
453
+ ]
454
+
455
+ [[package]]
456
+ name = "http-body"
457
+ version = "1.0.1"
458
+ source = "registry+https://github.com/rust-lang/crates.io-index"
459
+ checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
460
+ dependencies = [
461
+ "bytes",
462
+ "http",
463
+ ]
464
+
465
+ [[package]]
466
+ name = "http-body-util"
467
+ version = "0.1.3"
468
+ source = "registry+https://github.com/rust-lang/crates.io-index"
469
+ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
470
+ dependencies = [
471
+ "bytes",
472
+ "futures-core",
473
+ "http",
474
+ "http-body",
475
+ "pin-project-lite",
476
+ ]
477
+
478
+ [[package]]
479
+ name = "http-range-header"
480
+ version = "0.4.2"
481
+ source = "registry+https://github.com/rust-lang/crates.io-index"
482
+ checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
483
+
484
+ [[package]]
485
+ name = "httparse"
486
+ version = "1.10.1"
487
+ source = "registry+https://github.com/rust-lang/crates.io-index"
488
+ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
489
+
490
+ [[package]]
491
+ name = "httpdate"
492
+ version = "1.0.3"
493
+ source = "registry+https://github.com/rust-lang/crates.io-index"
494
+ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
495
+
496
+ [[package]]
497
+ name = "hyper"
498
+ version = "1.10.1"
499
+ source = "registry+https://github.com/rust-lang/crates.io-index"
500
+ checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
501
+ dependencies = [
502
+ "atomic-waker",
503
+ "bytes",
504
+ "futures-channel",
505
+ "futures-core",
506
+ "h2",
507
+ "http",
508
+ "http-body",
509
+ "httparse",
510
+ "httpdate",
511
+ "itoa",
512
+ "pin-project-lite",
513
+ "smallvec",
514
+ "tokio",
515
+ "want",
516
+ ]
517
+
518
+ [[package]]
519
+ name = "hyper-rustls"
520
+ version = "0.27.9"
521
+ source = "registry+https://github.com/rust-lang/crates.io-index"
522
+ checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
523
+ dependencies = [
524
+ "http",
525
+ "hyper",
526
+ "hyper-util",
527
+ "rustls",
528
+ "tokio",
529
+ "tokio-rustls",
530
+ "tower-service",
531
+ ]
532
+
533
+ [[package]]
534
+ name = "hyper-util"
535
+ version = "0.1.20"
536
+ source = "registry+https://github.com/rust-lang/crates.io-index"
537
+ checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
538
+ dependencies = [
539
+ "base64",
540
+ "bytes",
541
+ "futures-channel",
542
+ "futures-util",
543
+ "http",
544
+ "http-body",
545
+ "hyper",
546
+ "ipnet",
547
+ "libc",
548
+ "percent-encoding",
549
+ "pin-project-lite",
550
+ "socket2",
551
+ "system-configuration",
552
+ "tokio",
553
+ "tower-service",
554
+ "tracing",
555
+ "windows-registry",
556
+ ]
557
+
558
+ [[package]]
559
+ name = "icu_collections"
560
+ version = "2.2.0"
561
+ source = "registry+https://github.com/rust-lang/crates.io-index"
562
+ checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
563
+ dependencies = [
564
+ "displaydoc",
565
+ "potential_utf",
566
+ "utf8_iter",
567
+ "yoke",
568
+ "zerofrom",
569
+ "zerovec",
570
+ ]
571
+
572
+ [[package]]
573
+ name = "icu_locale_core"
574
+ version = "2.2.0"
575
+ source = "registry+https://github.com/rust-lang/crates.io-index"
576
+ checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
577
+ dependencies = [
578
+ "displaydoc",
579
+ "litemap",
580
+ "tinystr",
581
+ "writeable",
582
+ "zerovec",
583
+ ]
584
+
585
+ [[package]]
586
+ name = "icu_normalizer"
587
+ version = "2.2.0"
588
+ source = "registry+https://github.com/rust-lang/crates.io-index"
589
+ checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
590
+ dependencies = [
591
+ "icu_collections",
592
+ "icu_normalizer_data",
593
+ "icu_properties",
594
+ "icu_provider",
595
+ "smallvec",
596
+ "zerovec",
597
+ ]
598
+
599
+ [[package]]
600
+ name = "icu_normalizer_data"
601
+ version = "2.2.0"
602
+ source = "registry+https://github.com/rust-lang/crates.io-index"
603
+ checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
604
+
605
+ [[package]]
606
+ name = "icu_properties"
607
+ version = "2.2.0"
608
+ source = "registry+https://github.com/rust-lang/crates.io-index"
609
+ checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
610
+ dependencies = [
611
+ "icu_collections",
612
+ "icu_locale_core",
613
+ "icu_properties_data",
614
+ "icu_provider",
615
+ "zerotrie",
616
+ "zerovec",
617
+ ]
618
+
619
+ [[package]]
620
+ name = "icu_properties_data"
621
+ version = "2.2.0"
622
+ source = "registry+https://github.com/rust-lang/crates.io-index"
623
+ checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
624
+
625
+ [[package]]
626
+ name = "icu_provider"
627
+ version = "2.2.0"
628
+ source = "registry+https://github.com/rust-lang/crates.io-index"
629
+ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
630
+ dependencies = [
631
+ "displaydoc",
632
+ "icu_locale_core",
633
+ "writeable",
634
+ "yoke",
635
+ "zerofrom",
636
+ "zerotrie",
637
+ "zerovec",
638
+ ]
639
+
640
+ [[package]]
641
+ name = "id-arena"
642
+ version = "2.3.0"
643
+ source = "registry+https://github.com/rust-lang/crates.io-index"
644
+ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
645
+
646
+ [[package]]
647
+ name = "idna"
648
+ version = "1.1.0"
649
+ source = "registry+https://github.com/rust-lang/crates.io-index"
650
+ checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
651
+ dependencies = [
652
+ "idna_adapter",
653
+ "smallvec",
654
+ "utf8_iter",
655
+ ]
656
+
657
+ [[package]]
658
+ name = "idna_adapter"
659
+ version = "1.2.2"
660
+ source = "registry+https://github.com/rust-lang/crates.io-index"
661
+ checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
662
+ dependencies = [
663
+ "icu_normalizer",
664
+ "icu_properties",
665
+ ]
666
+
667
+ [[package]]
668
+ name = "include_dir"
669
+ version = "0.7.4"
670
+ source = "registry+https://github.com/rust-lang/crates.io-index"
671
+ checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
672
+ dependencies = [
673
+ "include_dir_macros",
674
+ ]
675
+
676
+ [[package]]
677
+ name = "include_dir_macros"
678
+ version = "0.7.4"
679
+ source = "registry+https://github.com/rust-lang/crates.io-index"
680
+ checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
681
+ dependencies = [
682
+ "proc-macro2",
683
+ "quote",
684
+ ]
685
+
686
+ [[package]]
687
+ name = "indexmap"
688
+ version = "2.14.0"
689
+ source = "registry+https://github.com/rust-lang/crates.io-index"
690
+ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
691
+ dependencies = [
692
+ "equivalent",
693
+ "hashbrown 0.17.1",
694
+ "serde",
695
+ "serde_core",
696
+ ]
697
+
698
+ [[package]]
699
+ name = "ipnet"
700
+ version = "2.12.0"
701
+ source = "registry+https://github.com/rust-lang/crates.io-index"
702
+ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
703
+
704
+ [[package]]
705
+ name = "itoa"
706
+ version = "1.0.18"
707
+ source = "registry+https://github.com/rust-lang/crates.io-index"
708
+ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
709
+
710
+ [[package]]
711
+ name = "jni"
712
+ version = "0.22.4"
713
+ source = "registry+https://github.com/rust-lang/crates.io-index"
714
+ checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
715
+ dependencies = [
716
+ "cfg-if",
717
+ "combine",
718
+ "jni-macros",
719
+ "jni-sys",
720
+ "log",
721
+ "simd_cesu8",
722
+ "thiserror",
723
+ "walkdir",
724
+ "windows-link",
725
+ ]
726
+
727
+ [[package]]
728
+ name = "jni-macros"
729
+ version = "0.22.4"
730
+ source = "registry+https://github.com/rust-lang/crates.io-index"
731
+ checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
732
+ dependencies = [
733
+ "proc-macro2",
734
+ "quote",
735
+ "rustc_version",
736
+ "simd_cesu8",
737
+ "syn",
738
+ ]
739
+
740
+ [[package]]
741
+ name = "jni-sys"
742
+ version = "0.4.1"
743
+ source = "registry+https://github.com/rust-lang/crates.io-index"
744
+ checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
745
+ dependencies = [
746
+ "jni-sys-macros",
747
+ ]
748
+
749
+ [[package]]
750
+ name = "jni-sys-macros"
751
+ version = "0.4.1"
752
+ source = "registry+https://github.com/rust-lang/crates.io-index"
753
+ checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
754
+ dependencies = [
755
+ "quote",
756
+ "syn",
757
+ ]
758
+
759
+ [[package]]
760
+ name = "jobserver"
761
+ version = "0.1.34"
762
+ source = "registry+https://github.com/rust-lang/crates.io-index"
763
+ checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
764
+ dependencies = [
765
+ "getrandom 0.3.4",
766
+ "libc",
767
+ ]
768
+
769
+ [[package]]
770
+ name = "js-sys"
771
+ version = "0.3.100"
772
+ source = "registry+https://github.com/rust-lang/crates.io-index"
773
+ checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
774
+ dependencies = [
775
+ "cfg-if",
776
+ "futures-util",
777
+ "wasm-bindgen",
778
+ ]
779
+
780
+ [[package]]
781
+ name = "lazy_static"
782
+ version = "1.5.0"
783
+ source = "registry+https://github.com/rust-lang/crates.io-index"
784
+ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
785
+
786
+ [[package]]
787
+ name = "leb128fmt"
788
+ version = "0.1.0"
789
+ source = "registry+https://github.com/rust-lang/crates.io-index"
790
+ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
791
+
792
+ [[package]]
793
+ name = "libc"
794
+ version = "0.2.186"
795
+ source = "registry+https://github.com/rust-lang/crates.io-index"
796
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
797
+
798
+ [[package]]
799
+ name = "litemap"
800
+ version = "0.8.2"
801
+ source = "registry+https://github.com/rust-lang/crates.io-index"
802
+ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
803
+
804
+ [[package]]
805
+ name = "lock_api"
806
+ version = "0.4.14"
807
+ source = "registry+https://github.com/rust-lang/crates.io-index"
808
+ checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
809
+ dependencies = [
810
+ "scopeguard",
811
+ ]
812
+
813
+ [[package]]
814
+ name = "log"
815
+ version = "0.4.32"
816
+ source = "registry+https://github.com/rust-lang/crates.io-index"
817
+ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
818
+
819
+ [[package]]
820
+ name = "lru-slab"
821
+ version = "0.1.2"
822
+ source = "registry+https://github.com/rust-lang/crates.io-index"
823
+ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
824
+
825
+ [[package]]
826
+ name = "matchers"
827
+ version = "0.2.0"
828
+ source = "registry+https://github.com/rust-lang/crates.io-index"
829
+ checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
830
+ dependencies = [
831
+ "regex-automata",
832
+ ]
833
+
834
+ [[package]]
835
+ name = "matchit"
836
+ version = "0.8.4"
837
+ source = "registry+https://github.com/rust-lang/crates.io-index"
838
+ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
839
+
840
+ [[package]]
841
+ name = "memchr"
842
+ version = "2.8.2"
843
+ source = "registry+https://github.com/rust-lang/crates.io-index"
844
+ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
845
+
846
+ [[package]]
847
+ name = "mime"
848
+ version = "0.3.17"
849
+ source = "registry+https://github.com/rust-lang/crates.io-index"
850
+ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
851
+
852
+ [[package]]
853
+ name = "mime_guess"
854
+ version = "2.0.5"
855
+ source = "registry+https://github.com/rust-lang/crates.io-index"
856
+ checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
857
+ dependencies = [
858
+ "mime",
859
+ "unicase",
860
+ ]
861
+
862
+ [[package]]
863
+ name = "mio"
864
+ version = "1.2.1"
865
+ source = "registry+https://github.com/rust-lang/crates.io-index"
866
+ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
867
+ dependencies = [
868
+ "libc",
869
+ "wasi",
870
+ "windows-sys 0.61.2",
871
+ ]
872
+
873
+ [[package]]
874
+ name = "nu-ansi-term"
875
+ version = "0.50.3"
876
+ source = "registry+https://github.com/rust-lang/crates.io-index"
877
+ checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
878
+ dependencies = [
879
+ "windows-sys 0.61.2",
880
+ ]
881
+
882
+ [[package]]
883
+ name = "num-format"
884
+ version = "0.4.4"
885
+ source = "registry+https://github.com/rust-lang/crates.io-index"
886
+ checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
887
+ dependencies = [
888
+ "arrayvec",
889
+ "itoa",
890
+ ]
891
+
892
+ [[package]]
893
+ name = "once_cell"
894
+ version = "1.21.4"
895
+ source = "registry+https://github.com/rust-lang/crates.io-index"
896
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
897
+
898
+ [[package]]
899
+ name = "openssl-probe"
900
+ version = "0.2.1"
901
+ source = "registry+https://github.com/rust-lang/crates.io-index"
902
+ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
903
+
904
+ [[package]]
905
+ name = "owo-colors"
906
+ version = "4.3.0"
907
+ source = "registry+https://github.com/rust-lang/crates.io-index"
908
+ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
909
+
910
+ [[package]]
911
+ name = "parking_lot"
912
+ version = "0.12.5"
913
+ source = "registry+https://github.com/rust-lang/crates.io-index"
914
+ checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
915
+ dependencies = [
916
+ "lock_api",
917
+ "parking_lot_core",
918
+ ]
919
+
920
+ [[package]]
921
+ name = "parking_lot_core"
922
+ version = "0.9.12"
923
+ source = "registry+https://github.com/rust-lang/crates.io-index"
924
+ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
925
+ dependencies = [
926
+ "cfg-if",
927
+ "libc",
928
+ "redox_syscall",
929
+ "smallvec",
930
+ "windows-link",
931
+ ]
932
+
933
+ [[package]]
934
+ name = "percent-encoding"
935
+ version = "2.3.2"
936
+ source = "registry+https://github.com/rust-lang/crates.io-index"
937
+ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
938
+
939
+ [[package]]
940
+ name = "pin-project-lite"
941
+ version = "0.2.17"
942
+ source = "registry+https://github.com/rust-lang/crates.io-index"
943
+ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
944
+
945
+ [[package]]
946
+ name = "potential_utf"
947
+ version = "0.1.5"
948
+ source = "registry+https://github.com/rust-lang/crates.io-index"
949
+ checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
950
+ dependencies = [
951
+ "zerovec",
952
+ ]
953
+
954
+ [[package]]
955
+ name = "ppv-lite86"
956
+ version = "0.2.21"
957
+ source = "registry+https://github.com/rust-lang/crates.io-index"
958
+ checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
959
+ dependencies = [
960
+ "zerocopy",
961
+ ]
962
+
963
+ [[package]]
964
+ name = "prettyplease"
965
+ version = "0.2.37"
966
+ source = "registry+https://github.com/rust-lang/crates.io-index"
967
+ checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
968
+ dependencies = [
969
+ "proc-macro2",
970
+ "syn",
971
+ ]
972
+
973
+ [[package]]
974
+ name = "proc-macro2"
975
+ version = "1.0.106"
976
+ source = "registry+https://github.com/rust-lang/crates.io-index"
977
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
978
+ dependencies = [
979
+ "unicode-ident",
980
+ ]
981
+
982
+ [[package]]
983
+ name = "quinn"
984
+ version = "0.11.9"
985
+ source = "registry+https://github.com/rust-lang/crates.io-index"
986
+ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
987
+ dependencies = [
988
+ "bytes",
989
+ "cfg_aliases",
990
+ "pin-project-lite",
991
+ "quinn-proto",
992
+ "quinn-udp",
993
+ "rustc-hash",
994
+ "rustls",
995
+ "socket2",
996
+ "thiserror",
997
+ "tokio",
998
+ "tracing",
999
+ "web-time",
1000
+ ]
1001
+
1002
+ [[package]]
1003
+ name = "quinn-proto"
1004
+ version = "0.11.14"
1005
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1006
+ checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
1007
+ dependencies = [
1008
+ "aws-lc-rs",
1009
+ "bytes",
1010
+ "getrandom 0.3.4",
1011
+ "lru-slab",
1012
+ "rand 0.9.4",
1013
+ "ring",
1014
+ "rustc-hash",
1015
+ "rustls",
1016
+ "rustls-pki-types",
1017
+ "slab",
1018
+ "thiserror",
1019
+ "tinyvec",
1020
+ "tracing",
1021
+ "web-time",
1022
+ ]
1023
+
1024
+ [[package]]
1025
+ name = "quinn-udp"
1026
+ version = "0.5.14"
1027
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1028
+ checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
1029
+ dependencies = [
1030
+ "cfg_aliases",
1031
+ "libc",
1032
+ "once_cell",
1033
+ "socket2",
1034
+ "tracing",
1035
+ "windows-sys 0.60.2",
1036
+ ]
1037
+
1038
+ [[package]]
1039
+ name = "quote"
1040
+ version = "1.0.45"
1041
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1042
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
1043
+ dependencies = [
1044
+ "proc-macro2",
1045
+ ]
1046
+
1047
+ [[package]]
1048
+ name = "r-efi"
1049
+ version = "5.3.0"
1050
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1051
+ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1052
+
1053
+ [[package]]
1054
+ name = "r-efi"
1055
+ version = "6.0.0"
1056
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1057
+ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1058
+
1059
+ [[package]]
1060
+ name = "rand"
1061
+ version = "0.9.4"
1062
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1063
+ checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
1064
+ dependencies = [
1065
+ "rand_chacha 0.9.0",
1066
+ "rand_core 0.9.5",
1067
+ ]
1068
+
1069
+ [[package]]
1070
+ name = "rand"
1071
+ version = "0.10.1"
1072
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1073
+ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
1074
+ dependencies = [
1075
+ "chacha20",
1076
+ "getrandom 0.4.2",
1077
+ "rand_core 0.10.1",
1078
+ ]
1079
+
1080
+ [[package]]
1081
+ name = "rand_chacha"
1082
+ version = "0.9.0"
1083
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1084
+ checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1085
+ dependencies = [
1086
+ "ppv-lite86",
1087
+ "rand_core 0.9.5",
1088
+ ]
1089
+
1090
+ [[package]]
1091
+ name = "rand_chacha"
1092
+ version = "0.10.0"
1093
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1094
+ checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
1095
+ dependencies = [
1096
+ "ppv-lite86",
1097
+ "rand_core 0.10.1",
1098
+ ]
1099
+
1100
+ [[package]]
1101
+ name = "rand_core"
1102
+ version = "0.9.5"
1103
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1104
+ checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1105
+ dependencies = [
1106
+ "getrandom 0.3.4",
1107
+ ]
1108
+
1109
+ [[package]]
1110
+ name = "rand_core"
1111
+ version = "0.10.1"
1112
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1113
+ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1114
+
1115
+ [[package]]
1116
+ name = "rayon"
1117
+ version = "1.12.0"
1118
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1119
+ checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
1120
+ dependencies = [
1121
+ "either",
1122
+ "rayon-core",
1123
+ ]
1124
+
1125
+ [[package]]
1126
+ name = "rayon-core"
1127
+ version = "1.13.0"
1128
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1129
+ checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
1130
+ dependencies = [
1131
+ "crossbeam-deque",
1132
+ "crossbeam-utils",
1133
+ ]
1134
+
1135
+ [[package]]
1136
+ name = "redox_syscall"
1137
+ version = "0.5.18"
1138
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1139
+ checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1140
+ dependencies = [
1141
+ "bitflags",
1142
+ ]
1143
+
1144
+ [[package]]
1145
+ name = "regex-automata"
1146
+ version = "0.4.14"
1147
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1148
+ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1149
+ dependencies = [
1150
+ "aho-corasick",
1151
+ "memchr",
1152
+ "regex-syntax",
1153
+ ]
1154
+
1155
+ [[package]]
1156
+ name = "regex-syntax"
1157
+ version = "0.8.11"
1158
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1159
+ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1160
+
1161
+ [[package]]
1162
+ name = "reqwest"
1163
+ version = "0.13.4"
1164
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1165
+ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
1166
+ dependencies = [
1167
+ "base64",
1168
+ "bytes",
1169
+ "encoding_rs",
1170
+ "futures-core",
1171
+ "h2",
1172
+ "http",
1173
+ "http-body",
1174
+ "http-body-util",
1175
+ "hyper",
1176
+ "hyper-rustls",
1177
+ "hyper-util",
1178
+ "js-sys",
1179
+ "log",
1180
+ "mime",
1181
+ "percent-encoding",
1182
+ "pin-project-lite",
1183
+ "quinn",
1184
+ "rustls",
1185
+ "rustls-pki-types",
1186
+ "rustls-platform-verifier",
1187
+ "serde",
1188
+ "serde_json",
1189
+ "sync_wrapper",
1190
+ "tokio",
1191
+ "tokio-rustls",
1192
+ "tower",
1193
+ "tower-http",
1194
+ "tower-service",
1195
+ "url",
1196
+ "wasm-bindgen",
1197
+ "wasm-bindgen-futures",
1198
+ "web-sys",
1199
+ ]
1200
+
1201
+ [[package]]
1202
+ name = "ring"
1203
+ version = "0.17.14"
1204
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1205
+ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1206
+ dependencies = [
1207
+ "cc",
1208
+ "cfg-if",
1209
+ "getrandom 0.2.17",
1210
+ "libc",
1211
+ "untrusted",
1212
+ "windows-sys 0.52.0",
1213
+ ]
1214
+
1215
+ [[package]]
1216
+ name = "rustc-hash"
1217
+ version = "2.1.2"
1218
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1219
+ checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
1220
+
1221
+ [[package]]
1222
+ name = "rustc_version"
1223
+ version = "0.4.1"
1224
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1225
+ checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1226
+ dependencies = [
1227
+ "semver",
1228
+ ]
1229
+
1230
+ [[package]]
1231
+ name = "rustls"
1232
+ version = "0.23.40"
1233
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1234
+ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
1235
+ dependencies = [
1236
+ "aws-lc-rs",
1237
+ "once_cell",
1238
+ "rustls-pki-types",
1239
+ "rustls-webpki",
1240
+ "subtle",
1241
+ "zeroize",
1242
+ ]
1243
+
1244
+ [[package]]
1245
+ name = "rustls-native-certs"
1246
+ version = "0.8.4"
1247
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1248
+ checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
1249
+ dependencies = [
1250
+ "openssl-probe",
1251
+ "rustls-pki-types",
1252
+ "schannel",
1253
+ "security-framework",
1254
+ ]
1255
+
1256
+ [[package]]
1257
+ name = "rustls-pki-types"
1258
+ version = "1.14.1"
1259
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1260
+ checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
1261
+ dependencies = [
1262
+ "web-time",
1263
+ "zeroize",
1264
+ ]
1265
+
1266
+ [[package]]
1267
+ name = "rustls-platform-verifier"
1268
+ version = "0.7.0"
1269
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1270
+ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
1271
+ dependencies = [
1272
+ "core-foundation 0.10.1",
1273
+ "core-foundation-sys",
1274
+ "jni",
1275
+ "log",
1276
+ "once_cell",
1277
+ "rustls",
1278
+ "rustls-native-certs",
1279
+ "rustls-platform-verifier-android",
1280
+ "rustls-webpki",
1281
+ "security-framework",
1282
+ "security-framework-sys",
1283
+ "webpki-root-certs",
1284
+ "windows-sys 0.61.2",
1285
+ ]
1286
+
1287
+ [[package]]
1288
+ name = "rustls-platform-verifier-android"
1289
+ version = "0.1.1"
1290
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1291
+ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
1292
+
1293
+ [[package]]
1294
+ name = "rustls-webpki"
1295
+ version = "0.103.13"
1296
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1297
+ checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
1298
+ dependencies = [
1299
+ "aws-lc-rs",
1300
+ "ring",
1301
+ "rustls-pki-types",
1302
+ "untrusted",
1303
+ ]
1304
+
1305
+ [[package]]
1306
+ name = "rustversion"
1307
+ version = "1.0.22"
1308
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1309
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1310
+
1311
+ [[package]]
1312
+ name = "ryu"
1313
+ version = "1.0.23"
1314
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1315
+ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1316
+
1317
+ [[package]]
1318
+ name = "same-file"
1319
+ version = "1.0.6"
1320
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1321
+ checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1322
+ dependencies = [
1323
+ "winapi-util",
1324
+ ]
1325
+
1326
+ [[package]]
1327
+ name = "schannel"
1328
+ version = "0.1.29"
1329
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1330
+ checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
1331
+ dependencies = [
1332
+ "windows-sys 0.61.2",
1333
+ ]
1334
+
1335
+ [[package]]
1336
+ name = "scopeguard"
1337
+ version = "1.2.0"
1338
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1339
+ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1340
+
1341
+ [[package]]
1342
+ name = "security-framework"
1343
+ version = "3.7.0"
1344
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1345
+ checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1346
+ dependencies = [
1347
+ "bitflags",
1348
+ "core-foundation 0.10.1",
1349
+ "core-foundation-sys",
1350
+ "libc",
1351
+ "security-framework-sys",
1352
+ ]
1353
+
1354
+ [[package]]
1355
+ name = "security-framework-sys"
1356
+ version = "2.17.0"
1357
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1358
+ checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
1359
+ dependencies = [
1360
+ "core-foundation-sys",
1361
+ "libc",
1362
+ ]
1363
+
1364
+ [[package]]
1365
+ name = "semver"
1366
+ version = "1.0.28"
1367
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1368
+ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1369
+
1370
+ [[package]]
1371
+ name = "serde"
1372
+ version = "1.0.228"
1373
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1374
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1375
+ dependencies = [
1376
+ "serde_core",
1377
+ "serde_derive",
1378
+ ]
1379
+
1380
+ [[package]]
1381
+ name = "serde_core"
1382
+ version = "1.0.228"
1383
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1384
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1385
+ dependencies = [
1386
+ "serde_derive",
1387
+ ]
1388
+
1389
+ [[package]]
1390
+ name = "serde_derive"
1391
+ version = "1.0.228"
1392
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1393
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1394
+ dependencies = [
1395
+ "proc-macro2",
1396
+ "quote",
1397
+ "syn",
1398
+ ]
1399
+
1400
+ [[package]]
1401
+ name = "serde_json"
1402
+ version = "1.0.150"
1403
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1404
+ checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
1405
+ dependencies = [
1406
+ "itoa",
1407
+ "memchr",
1408
+ "serde",
1409
+ "serde_core",
1410
+ "zmij",
1411
+ ]
1412
+
1413
+ [[package]]
1414
+ name = "serde_path_to_error"
1415
+ version = "0.1.20"
1416
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1417
+ checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
1418
+ dependencies = [
1419
+ "itoa",
1420
+ "serde",
1421
+ "serde_core",
1422
+ ]
1423
+
1424
+ [[package]]
1425
+ name = "serde_spanned"
1426
+ version = "1.1.1"
1427
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1428
+ checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
1429
+ dependencies = [
1430
+ "serde_core",
1431
+ ]
1432
+
1433
+ [[package]]
1434
+ name = "serde_urlencoded"
1435
+ version = "0.7.1"
1436
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1437
+ checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1438
+ dependencies = [
1439
+ "form_urlencoded",
1440
+ "itoa",
1441
+ "ryu",
1442
+ "serde",
1443
+ ]
1444
+
1445
+ [[package]]
1446
+ name = "serde_yaml"
1447
+ version = "0.9.34+deprecated"
1448
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1449
+ checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
1450
+ dependencies = [
1451
+ "indexmap",
1452
+ "itoa",
1453
+ "ryu",
1454
+ "serde",
1455
+ "unsafe-libyaml",
1456
+ ]
1457
+
1458
+ [[package]]
1459
+ name = "sharded-slab"
1460
+ version = "0.1.7"
1461
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1462
+ checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1463
+ dependencies = [
1464
+ "lazy_static",
1465
+ ]
1466
+
1467
+ [[package]]
1468
+ name = "shlex"
1469
+ version = "2.0.1"
1470
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1471
+ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1472
+
1473
+ [[package]]
1474
+ name = "signal-hook-registry"
1475
+ version = "1.4.8"
1476
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1477
+ checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1478
+ dependencies = [
1479
+ "errno",
1480
+ "libc",
1481
+ ]
1482
+
1483
+ [[package]]
1484
+ name = "simd_cesu8"
1485
+ version = "1.1.1"
1486
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1487
+ checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
1488
+ dependencies = [
1489
+ "rustc_version",
1490
+ "simdutf8",
1491
+ ]
1492
+
1493
+ [[package]]
1494
+ name = "simdutf8"
1495
+ version = "0.1.5"
1496
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1497
+ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
1498
+
1499
+ [[package]]
1500
+ name = "slab"
1501
+ version = "0.4.12"
1502
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1503
+ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1504
+
1505
+ [[package]]
1506
+ name = "smallvec"
1507
+ version = "1.15.2"
1508
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1509
+ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1510
+
1511
+ [[package]]
1512
+ name = "socket2"
1513
+ version = "0.6.4"
1514
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1515
+ checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
1516
+ dependencies = [
1517
+ "libc",
1518
+ "windows-sys 0.61.2",
1519
+ ]
1520
+
1521
+ [[package]]
1522
+ name = "solverforge"
1523
+ version = "0.19.3"
1524
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1525
+ checksum = "15d0a5dfb480a56ec3ee9aa048fd54bba3e65b04866bde610c1a956b89b4da80"
1526
+ dependencies = [
1527
+ "solverforge-bridge",
1528
+ "solverforge-config",
1529
+ "solverforge-console",
1530
+ "solverforge-core",
1531
+ "solverforge-cvrp",
1532
+ "solverforge-macros",
1533
+ "solverforge-scoring",
1534
+ "solverforge-solver",
1535
+ ]
1536
+
1537
+ [[package]]
1538
+ name = "solverforge-bridge"
1539
+ version = "0.19.3"
1540
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1541
+ checksum = "c78f964f79318c3c0ee3161ed3004c708f04c4a751f784fe7d0ccc908194aaa3"
1542
+ dependencies = [
1543
+ "solverforge-config",
1544
+ "solverforge-core",
1545
+ "solverforge-scoring",
1546
+ "solverforge-solver",
1547
+ ]
1548
+
1549
+ [[package]]
1550
+ name = "solverforge-config"
1551
+ version = "0.19.3"
1552
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1553
+ checksum = "27d4cb13eb5097514c5d18eeddb0f88521e3f3d8d402a0d22ffbbdcd08c90c21"
1554
+ dependencies = [
1555
+ "serde",
1556
+ "serde_yaml",
1557
+ "solverforge-core",
1558
+ "thiserror",
1559
+ "toml",
1560
+ ]
1561
+
1562
+ [[package]]
1563
+ name = "solverforge-console"
1564
+ version = "0.19.3"
1565
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1566
+ checksum = "3f64e369266512f53d2bd335592975193fdc4d8c2d6da99929f152edfd721562"
1567
+ dependencies = [
1568
+ "num-format",
1569
+ "owo-colors",
1570
+ "tracing",
1571
+ "tracing-subscriber",
1572
+ ]
1573
+
1574
+ [[package]]
1575
+ name = "solverforge-core"
1576
+ version = "0.19.3"
1577
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1578
+ checksum = "e6118e7d98c3c8ef58646b2779603dd1ab6f9dc9f25481cb104402cbcd06be46"
1579
+ dependencies = [
1580
+ "serde",
1581
+ "thiserror",
1582
+ ]
1583
+
1584
+ [[package]]
1585
+ name = "solverforge-cvrp"
1586
+ version = "0.19.3"
1587
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1588
+ checksum = "554c12afb5908f396833688be18e649eee5967df8c41c0e4f20f0a10445ff651"
1589
+ dependencies = [
1590
+ "solverforge-solver",
1591
+ ]
1592
+
1593
+ [[package]]
1594
+ name = "solverforge-fsr"
1595
+ version = "2.0.7"
1596
+ dependencies = [
1597
+ "axum",
1598
+ "parking_lot",
1599
+ "serde",
1600
+ "serde_json",
1601
+ "solverforge",
1602
+ "solverforge-core",
1603
+ "solverforge-maps",
1604
+ "solverforge-ui",
1605
+ "tokio",
1606
+ "tokio-stream",
1607
+ "tower",
1608
+ "tower-http",
1609
+ "uuid",
1610
+ ]
1611
+
1612
+ [[package]]
1613
+ name = "solverforge-macros"
1614
+ version = "0.19.3"
1615
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1616
+ checksum = "22c15f305806b185fc4815f8da0ec73acb9acbba5de3583433ab7b6bb2eeffa4"
1617
+ dependencies = [
1618
+ "proc-macro2",
1619
+ "quote",
1620
+ "syn",
1621
+ ]
1622
+
1623
+ [[package]]
1624
+ name = "solverforge-maps"
1625
+ version = "2.1.4"
1626
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1627
+ checksum = "e31f816d221238ba3ade93315e6a605486b53d9ec26527477d165b507ece6a88"
1628
+ dependencies = [
1629
+ "rayon",
1630
+ "reqwest",
1631
+ "serde",
1632
+ "serde_json",
1633
+ "tokio",
1634
+ "tracing",
1635
+ "utoipa",
1636
+ ]
1637
+
1638
+ [[package]]
1639
+ name = "solverforge-scoring"
1640
+ version = "0.19.3"
1641
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1642
+ checksum = "3ddf9f9af52b0d2525f581f921e41fac8352dc2f6f1deb7f9100aff9cfedba25"
1643
+ dependencies = [
1644
+ "solverforge-core",
1645
+ "thiserror",
1646
+ ]
1647
+
1648
+ [[package]]
1649
+ name = "solverforge-solver"
1650
+ version = "0.19.3"
1651
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1652
+ checksum = "19d30aece798a48d08edc630734a181dd2d9bcc1e806de7ec27701f9885989a6"
1653
+ dependencies = [
1654
+ "rand 0.10.1",
1655
+ "rand_chacha 0.10.0",
1656
+ "rayon",
1657
+ "serde",
1658
+ "smallvec",
1659
+ "solverforge-config",
1660
+ "solverforge-core",
1661
+ "solverforge-scoring",
1662
+ "thiserror",
1663
+ "tokio",
1664
+ "tracing",
1665
+ ]
1666
+
1667
+ [[package]]
1668
+ name = "solverforge-ui"
1669
+ version = "0.6.5"
1670
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1671
+ checksum = "1c7fa2d78c84af9a1e264adcffc1bdf8cb4edab8d73a3543fb448d166c95596f"
1672
+ dependencies = [
1673
+ "axum",
1674
+ "include_dir",
1675
+ ]
1676
+
1677
+ [[package]]
1678
+ name = "stable_deref_trait"
1679
+ version = "1.2.1"
1680
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1681
+ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1682
+
1683
+ [[package]]
1684
+ name = "subtle"
1685
+ version = "2.6.1"
1686
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1687
+ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1688
+
1689
+ [[package]]
1690
+ name = "syn"
1691
+ version = "2.0.117"
1692
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1693
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
1694
+ dependencies = [
1695
+ "proc-macro2",
1696
+ "quote",
1697
+ "unicode-ident",
1698
+ ]
1699
+
1700
+ [[package]]
1701
+ name = "sync_wrapper"
1702
+ version = "1.0.2"
1703
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1704
+ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1705
+ dependencies = [
1706
+ "futures-core",
1707
+ ]
1708
+
1709
+ [[package]]
1710
+ name = "synstructure"
1711
+ version = "0.13.2"
1712
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1713
+ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1714
+ dependencies = [
1715
+ "proc-macro2",
1716
+ "quote",
1717
+ "syn",
1718
+ ]
1719
+
1720
+ [[package]]
1721
+ name = "system-configuration"
1722
+ version = "0.7.0"
1723
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1724
+ checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
1725
+ dependencies = [
1726
+ "bitflags",
1727
+ "core-foundation 0.9.4",
1728
+ "system-configuration-sys",
1729
+ ]
1730
+
1731
+ [[package]]
1732
+ name = "system-configuration-sys"
1733
+ version = "0.6.0"
1734
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1735
+ checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
1736
+ dependencies = [
1737
+ "core-foundation-sys",
1738
+ "libc",
1739
+ ]
1740
+
1741
+ [[package]]
1742
+ name = "thiserror"
1743
+ version = "2.0.18"
1744
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1745
+ checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1746
+ dependencies = [
1747
+ "thiserror-impl",
1748
+ ]
1749
+
1750
+ [[package]]
1751
+ name = "thiserror-impl"
1752
+ version = "2.0.18"
1753
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1754
+ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1755
+ dependencies = [
1756
+ "proc-macro2",
1757
+ "quote",
1758
+ "syn",
1759
+ ]
1760
+
1761
+ [[package]]
1762
+ name = "thread_local"
1763
+ version = "1.1.9"
1764
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1765
+ checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
1766
+ dependencies = [
1767
+ "cfg-if",
1768
+ ]
1769
+
1770
+ [[package]]
1771
+ name = "tinystr"
1772
+ version = "0.8.3"
1773
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1774
+ checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
1775
+ dependencies = [
1776
+ "displaydoc",
1777
+ "zerovec",
1778
+ ]
1779
+
1780
+ [[package]]
1781
+ name = "tinyvec"
1782
+ version = "1.11.0"
1783
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1784
+ checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
1785
+ dependencies = [
1786
+ "tinyvec_macros",
1787
+ ]
1788
+
1789
+ [[package]]
1790
+ name = "tinyvec_macros"
1791
+ version = "0.1.1"
1792
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1793
+ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1794
+
1795
+ [[package]]
1796
+ name = "tokio"
1797
+ version = "1.52.3"
1798
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1799
+ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
1800
+ dependencies = [
1801
+ "bytes",
1802
+ "libc",
1803
+ "mio",
1804
+ "parking_lot",
1805
+ "pin-project-lite",
1806
+ "signal-hook-registry",
1807
+ "socket2",
1808
+ "tokio-macros",
1809
+ "windows-sys 0.61.2",
1810
+ ]
1811
+
1812
+ [[package]]
1813
+ name = "tokio-macros"
1814
+ version = "2.7.0"
1815
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1816
+ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
1817
+ dependencies = [
1818
+ "proc-macro2",
1819
+ "quote",
1820
+ "syn",
1821
+ ]
1822
+
1823
+ [[package]]
1824
+ name = "tokio-rustls"
1825
+ version = "0.26.4"
1826
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1827
+ checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
1828
+ dependencies = [
1829
+ "rustls",
1830
+ "tokio",
1831
+ ]
1832
+
1833
+ [[package]]
1834
+ name = "tokio-stream"
1835
+ version = "0.1.18"
1836
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1837
+ checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
1838
+ dependencies = [
1839
+ "futures-core",
1840
+ "pin-project-lite",
1841
+ "tokio",
1842
+ "tokio-util",
1843
+ ]
1844
+
1845
+ [[package]]
1846
+ name = "tokio-util"
1847
+ version = "0.7.18"
1848
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1849
+ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
1850
+ dependencies = [
1851
+ "bytes",
1852
+ "futures-core",
1853
+ "futures-sink",
1854
+ "pin-project-lite",
1855
+ "tokio",
1856
+ ]
1857
+
1858
+ [[package]]
1859
+ name = "toml"
1860
+ version = "1.1.2+spec-1.1.0"
1861
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1862
+ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
1863
+ dependencies = [
1864
+ "indexmap",
1865
+ "serde_core",
1866
+ "serde_spanned",
1867
+ "toml_datetime",
1868
+ "toml_parser",
1869
+ "toml_writer",
1870
+ "winnow",
1871
+ ]
1872
+
1873
+ [[package]]
1874
+ name = "toml_datetime"
1875
+ version = "1.1.1+spec-1.1.0"
1876
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1877
+ checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
1878
+ dependencies = [
1879
+ "serde_core",
1880
+ ]
1881
+
1882
+ [[package]]
1883
+ name = "toml_parser"
1884
+ version = "1.1.2+spec-1.1.0"
1885
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1886
+ checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
1887
+ dependencies = [
1888
+ "winnow",
1889
+ ]
1890
+
1891
+ [[package]]
1892
+ name = "toml_writer"
1893
+ version = "1.1.1+spec-1.1.0"
1894
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1895
+ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
1896
+
1897
+ [[package]]
1898
+ name = "tower"
1899
+ version = "0.5.3"
1900
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1901
+ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
1902
+ dependencies = [
1903
+ "futures-core",
1904
+ "futures-util",
1905
+ "pin-project-lite",
1906
+ "sync_wrapper",
1907
+ "tokio",
1908
+ "tower-layer",
1909
+ "tower-service",
1910
+ "tracing",
1911
+ ]
1912
+
1913
+ [[package]]
1914
+ name = "tower-http"
1915
+ version = "0.6.11"
1916
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1917
+ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
1918
+ dependencies = [
1919
+ "bitflags",
1920
+ "bytes",
1921
+ "futures-core",
1922
+ "futures-util",
1923
+ "http",
1924
+ "http-body",
1925
+ "http-body-util",
1926
+ "http-range-header",
1927
+ "httpdate",
1928
+ "mime",
1929
+ "mime_guess",
1930
+ "percent-encoding",
1931
+ "pin-project-lite",
1932
+ "tokio",
1933
+ "tokio-util",
1934
+ "tower",
1935
+ "tower-layer",
1936
+ "tower-service",
1937
+ "url",
1938
+ ]
1939
+
1940
+ [[package]]
1941
+ name = "tower-layer"
1942
+ version = "0.3.3"
1943
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1944
+ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
1945
+
1946
+ [[package]]
1947
+ name = "tower-service"
1948
+ version = "0.3.3"
1949
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1950
+ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
1951
+
1952
+ [[package]]
1953
+ name = "tracing"
1954
+ version = "0.1.44"
1955
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1956
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1957
+ dependencies = [
1958
+ "log",
1959
+ "pin-project-lite",
1960
+ "tracing-attributes",
1961
+ "tracing-core",
1962
+ ]
1963
+
1964
+ [[package]]
1965
+ name = "tracing-attributes"
1966
+ version = "0.1.31"
1967
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1968
+ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1969
+ dependencies = [
1970
+ "proc-macro2",
1971
+ "quote",
1972
+ "syn",
1973
+ ]
1974
+
1975
+ [[package]]
1976
+ name = "tracing-core"
1977
+ version = "0.1.36"
1978
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1979
+ checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1980
+ dependencies = [
1981
+ "once_cell",
1982
+ "valuable",
1983
+ ]
1984
+
1985
+ [[package]]
1986
+ name = "tracing-log"
1987
+ version = "0.2.0"
1988
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1989
+ checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1990
+ dependencies = [
1991
+ "log",
1992
+ "once_cell",
1993
+ "tracing-core",
1994
+ ]
1995
+
1996
+ [[package]]
1997
+ name = "tracing-subscriber"
1998
+ version = "0.3.23"
1999
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2000
+ checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
2001
+ dependencies = [
2002
+ "matchers",
2003
+ "nu-ansi-term",
2004
+ "once_cell",
2005
+ "regex-automata",
2006
+ "sharded-slab",
2007
+ "smallvec",
2008
+ "thread_local",
2009
+ "tracing",
2010
+ "tracing-core",
2011
+ "tracing-log",
2012
+ ]
2013
+
2014
+ [[package]]
2015
+ name = "try-lock"
2016
+ version = "0.2.5"
2017
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2018
+ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2019
+
2020
+ [[package]]
2021
+ name = "unicase"
2022
+ version = "2.9.0"
2023
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2024
+ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
2025
+
2026
+ [[package]]
2027
+ name = "unicode-ident"
2028
+ version = "1.0.24"
2029
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2030
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2031
+
2032
+ [[package]]
2033
+ name = "unicode-xid"
2034
+ version = "0.2.6"
2035
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2036
+ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2037
+
2038
+ [[package]]
2039
+ name = "unsafe-libyaml"
2040
+ version = "0.2.11"
2041
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2042
+ checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
2043
+
2044
+ [[package]]
2045
+ name = "untrusted"
2046
+ version = "0.9.0"
2047
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2048
+ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2049
+
2050
+ [[package]]
2051
+ name = "url"
2052
+ version = "2.5.8"
2053
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2054
+ checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2055
+ dependencies = [
2056
+ "form_urlencoded",
2057
+ "idna",
2058
+ "percent-encoding",
2059
+ "serde",
2060
+ ]
2061
+
2062
+ [[package]]
2063
+ name = "utf8_iter"
2064
+ version = "1.0.4"
2065
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2066
+ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2067
+
2068
+ [[package]]
2069
+ name = "utoipa"
2070
+ version = "5.5.0"
2071
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2072
+ checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
2073
+ dependencies = [
2074
+ "indexmap",
2075
+ "serde",
2076
+ "serde_json",
2077
+ "utoipa-gen",
2078
+ ]
2079
+
2080
+ [[package]]
2081
+ name = "utoipa-gen"
2082
+ version = "5.5.0"
2083
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2084
+ checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
2085
+ dependencies = [
2086
+ "proc-macro2",
2087
+ "quote",
2088
+ "syn",
2089
+ ]
2090
+
2091
+ [[package]]
2092
+ name = "uuid"
2093
+ version = "1.23.3"
2094
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2095
+ checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
2096
+ dependencies = [
2097
+ "getrandom 0.4.2",
2098
+ "js-sys",
2099
+ "serde_core",
2100
+ "wasm-bindgen",
2101
+ ]
2102
+
2103
+ [[package]]
2104
+ name = "valuable"
2105
+ version = "0.1.1"
2106
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2107
+ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
2108
+
2109
+ [[package]]
2110
+ name = "walkdir"
2111
+ version = "2.5.0"
2112
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2113
+ checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2114
+ dependencies = [
2115
+ "same-file",
2116
+ "winapi-util",
2117
+ ]
2118
+
2119
+ [[package]]
2120
+ name = "want"
2121
+ version = "0.3.1"
2122
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2123
+ checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
2124
+ dependencies = [
2125
+ "try-lock",
2126
+ ]
2127
+
2128
+ [[package]]
2129
+ name = "wasi"
2130
+ version = "0.11.1+wasi-snapshot-preview1"
2131
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2132
+ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2133
+
2134
+ [[package]]
2135
+ name = "wasip2"
2136
+ version = "1.0.3+wasi-0.2.9"
2137
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2138
+ checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
2139
+ dependencies = [
2140
+ "wit-bindgen 0.57.1",
2141
+ ]
2142
+
2143
+ [[package]]
2144
+ name = "wasip3"
2145
+ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
2146
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2147
+ checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
2148
+ dependencies = [
2149
+ "wit-bindgen 0.51.0",
2150
+ ]
2151
+
2152
+ [[package]]
2153
+ name = "wasm-bindgen"
2154
+ version = "0.2.123"
2155
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2156
+ checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
2157
+ dependencies = [
2158
+ "cfg-if",
2159
+ "once_cell",
2160
+ "rustversion",
2161
+ "wasm-bindgen-macro",
2162
+ "wasm-bindgen-shared",
2163
+ ]
2164
+
2165
+ [[package]]
2166
+ name = "wasm-bindgen-futures"
2167
+ version = "0.4.73"
2168
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2169
+ checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
2170
+ dependencies = [
2171
+ "js-sys",
2172
+ "wasm-bindgen",
2173
+ ]
2174
+
2175
+ [[package]]
2176
+ name = "wasm-bindgen-macro"
2177
+ version = "0.2.123"
2178
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2179
+ checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
2180
+ dependencies = [
2181
+ "quote",
2182
+ "wasm-bindgen-macro-support",
2183
+ ]
2184
+
2185
+ [[package]]
2186
+ name = "wasm-bindgen-macro-support"
2187
+ version = "0.2.123"
2188
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2189
+ checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
2190
+ dependencies = [
2191
+ "bumpalo",
2192
+ "proc-macro2",
2193
+ "quote",
2194
+ "syn",
2195
+ "wasm-bindgen-shared",
2196
+ ]
2197
+
2198
+ [[package]]
2199
+ name = "wasm-bindgen-shared"
2200
+ version = "0.2.123"
2201
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2202
+ checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
2203
+ dependencies = [
2204
+ "unicode-ident",
2205
+ ]
2206
+
2207
+ [[package]]
2208
+ name = "wasm-encoder"
2209
+ version = "0.244.0"
2210
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2211
+ checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
2212
+ dependencies = [
2213
+ "leb128fmt",
2214
+ "wasmparser",
2215
+ ]
2216
+
2217
+ [[package]]
2218
+ name = "wasm-metadata"
2219
+ version = "0.244.0"
2220
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2221
+ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
2222
+ dependencies = [
2223
+ "anyhow",
2224
+ "indexmap",
2225
+ "wasm-encoder",
2226
+ "wasmparser",
2227
+ ]
2228
+
2229
+ [[package]]
2230
+ name = "wasmparser"
2231
+ version = "0.244.0"
2232
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2233
+ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
2234
+ dependencies = [
2235
+ "bitflags",
2236
+ "hashbrown 0.15.5",
2237
+ "indexmap",
2238
+ "semver",
2239
+ ]
2240
+
2241
+ [[package]]
2242
+ name = "web-sys"
2243
+ version = "0.3.100"
2244
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2245
+ checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
2246
+ dependencies = [
2247
+ "js-sys",
2248
+ "wasm-bindgen",
2249
+ ]
2250
+
2251
+ [[package]]
2252
+ name = "web-time"
2253
+ version = "1.1.0"
2254
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2255
+ checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2256
+ dependencies = [
2257
+ "js-sys",
2258
+ "wasm-bindgen",
2259
+ ]
2260
+
2261
+ [[package]]
2262
+ name = "webpki-root-certs"
2263
+ version = "1.0.7"
2264
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2265
+ checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
2266
+ dependencies = [
2267
+ "rustls-pki-types",
2268
+ ]
2269
+
2270
+ [[package]]
2271
+ name = "winapi-util"
2272
+ version = "0.1.11"
2273
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2274
+ checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2275
+ dependencies = [
2276
+ "windows-sys 0.61.2",
2277
+ ]
2278
+
2279
+ [[package]]
2280
+ name = "windows-link"
2281
+ version = "0.2.1"
2282
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2283
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2284
+
2285
+ [[package]]
2286
+ name = "windows-registry"
2287
+ version = "0.6.1"
2288
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2289
+ checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
2290
+ dependencies = [
2291
+ "windows-link",
2292
+ "windows-result",
2293
+ "windows-strings",
2294
+ ]
2295
+
2296
+ [[package]]
2297
+ name = "windows-result"
2298
+ version = "0.4.1"
2299
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2300
+ checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2301
+ dependencies = [
2302
+ "windows-link",
2303
+ ]
2304
+
2305
+ [[package]]
2306
+ name = "windows-strings"
2307
+ version = "0.5.1"
2308
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2309
+ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2310
+ dependencies = [
2311
+ "windows-link",
2312
+ ]
2313
+
2314
+ [[package]]
2315
+ name = "windows-sys"
2316
+ version = "0.52.0"
2317
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2318
+ checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2319
+ dependencies = [
2320
+ "windows-targets 0.52.6",
2321
+ ]
2322
+
2323
+ [[package]]
2324
+ name = "windows-sys"
2325
+ version = "0.60.2"
2326
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2327
+ checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
2328
+ dependencies = [
2329
+ "windows-targets 0.53.5",
2330
+ ]
2331
+
2332
+ [[package]]
2333
+ name = "windows-sys"
2334
+ version = "0.61.2"
2335
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2336
+ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2337
+ dependencies = [
2338
+ "windows-link",
2339
+ ]
2340
+
2341
+ [[package]]
2342
+ name = "windows-targets"
2343
+ version = "0.52.6"
2344
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2345
+ checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2346
+ dependencies = [
2347
+ "windows_aarch64_gnullvm 0.52.6",
2348
+ "windows_aarch64_msvc 0.52.6",
2349
+ "windows_i686_gnu 0.52.6",
2350
+ "windows_i686_gnullvm 0.52.6",
2351
+ "windows_i686_msvc 0.52.6",
2352
+ "windows_x86_64_gnu 0.52.6",
2353
+ "windows_x86_64_gnullvm 0.52.6",
2354
+ "windows_x86_64_msvc 0.52.6",
2355
+ ]
2356
+
2357
+ [[package]]
2358
+ name = "windows-targets"
2359
+ version = "0.53.5"
2360
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2361
+ checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
2362
+ dependencies = [
2363
+ "windows-link",
2364
+ "windows_aarch64_gnullvm 0.53.1",
2365
+ "windows_aarch64_msvc 0.53.1",
2366
+ "windows_i686_gnu 0.53.1",
2367
+ "windows_i686_gnullvm 0.53.1",
2368
+ "windows_i686_msvc 0.53.1",
2369
+ "windows_x86_64_gnu 0.53.1",
2370
+ "windows_x86_64_gnullvm 0.53.1",
2371
+ "windows_x86_64_msvc 0.53.1",
2372
+ ]
2373
+
2374
+ [[package]]
2375
+ name = "windows_aarch64_gnullvm"
2376
+ version = "0.52.6"
2377
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2378
+ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2379
+
2380
+ [[package]]
2381
+ name = "windows_aarch64_gnullvm"
2382
+ version = "0.53.1"
2383
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2384
+ checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
2385
+
2386
+ [[package]]
2387
+ name = "windows_aarch64_msvc"
2388
+ version = "0.52.6"
2389
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2390
+ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2391
+
2392
+ [[package]]
2393
+ name = "windows_aarch64_msvc"
2394
+ version = "0.53.1"
2395
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2396
+ checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
2397
+
2398
+ [[package]]
2399
+ name = "windows_i686_gnu"
2400
+ version = "0.52.6"
2401
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2402
+ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2403
+
2404
+ [[package]]
2405
+ name = "windows_i686_gnu"
2406
+ version = "0.53.1"
2407
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2408
+ checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
2409
+
2410
+ [[package]]
2411
+ name = "windows_i686_gnullvm"
2412
+ version = "0.52.6"
2413
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2414
+ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2415
+
2416
+ [[package]]
2417
+ name = "windows_i686_gnullvm"
2418
+ version = "0.53.1"
2419
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2420
+ checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
2421
+
2422
+ [[package]]
2423
+ name = "windows_i686_msvc"
2424
+ version = "0.52.6"
2425
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2426
+ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2427
+
2428
+ [[package]]
2429
+ name = "windows_i686_msvc"
2430
+ version = "0.53.1"
2431
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2432
+ checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
2433
+
2434
+ [[package]]
2435
+ name = "windows_x86_64_gnu"
2436
+ version = "0.52.6"
2437
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2438
+ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2439
+
2440
+ [[package]]
2441
+ name = "windows_x86_64_gnu"
2442
+ version = "0.53.1"
2443
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2444
+ checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
2445
+
2446
+ [[package]]
2447
+ name = "windows_x86_64_gnullvm"
2448
+ version = "0.52.6"
2449
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2450
+ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2451
+
2452
+ [[package]]
2453
+ name = "windows_x86_64_gnullvm"
2454
+ version = "0.53.1"
2455
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2456
+ checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
2457
+
2458
+ [[package]]
2459
+ name = "windows_x86_64_msvc"
2460
+ version = "0.52.6"
2461
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2462
+ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2463
+
2464
+ [[package]]
2465
+ name = "windows_x86_64_msvc"
2466
+ version = "0.53.1"
2467
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2468
+ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
2469
+
2470
+ [[package]]
2471
+ name = "winnow"
2472
+ version = "1.0.3"
2473
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2474
+ checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
2475
+
2476
+ [[package]]
2477
+ name = "wit-bindgen"
2478
+ version = "0.51.0"
2479
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2480
+ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
2481
+ dependencies = [
2482
+ "wit-bindgen-rust-macro",
2483
+ ]
2484
+
2485
+ [[package]]
2486
+ name = "wit-bindgen"
2487
+ version = "0.57.1"
2488
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2489
+ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
2490
+
2491
+ [[package]]
2492
+ name = "wit-bindgen-core"
2493
+ version = "0.51.0"
2494
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2495
+ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
2496
+ dependencies = [
2497
+ "anyhow",
2498
+ "heck",
2499
+ "wit-parser",
2500
+ ]
2501
+
2502
+ [[package]]
2503
+ name = "wit-bindgen-rust"
2504
+ version = "0.51.0"
2505
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2506
+ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
2507
+ dependencies = [
2508
+ "anyhow",
2509
+ "heck",
2510
+ "indexmap",
2511
+ "prettyplease",
2512
+ "syn",
2513
+ "wasm-metadata",
2514
+ "wit-bindgen-core",
2515
+ "wit-component",
2516
+ ]
2517
+
2518
+ [[package]]
2519
+ name = "wit-bindgen-rust-macro"
2520
+ version = "0.51.0"
2521
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2522
+ checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
2523
+ dependencies = [
2524
+ "anyhow",
2525
+ "prettyplease",
2526
+ "proc-macro2",
2527
+ "quote",
2528
+ "syn",
2529
+ "wit-bindgen-core",
2530
+ "wit-bindgen-rust",
2531
+ ]
2532
+
2533
+ [[package]]
2534
+ name = "wit-component"
2535
+ version = "0.244.0"
2536
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2537
+ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
2538
+ dependencies = [
2539
+ "anyhow",
2540
+ "bitflags",
2541
+ "indexmap",
2542
+ "log",
2543
+ "serde",
2544
+ "serde_derive",
2545
+ "serde_json",
2546
+ "wasm-encoder",
2547
+ "wasm-metadata",
2548
+ "wasmparser",
2549
+ "wit-parser",
2550
+ ]
2551
+
2552
+ [[package]]
2553
+ name = "wit-parser"
2554
+ version = "0.244.0"
2555
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2556
+ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
2557
+ dependencies = [
2558
+ "anyhow",
2559
+ "id-arena",
2560
+ "indexmap",
2561
+ "log",
2562
+ "semver",
2563
+ "serde",
2564
+ "serde_derive",
2565
+ "serde_json",
2566
+ "unicode-xid",
2567
+ "wasmparser",
2568
+ ]
2569
+
2570
+ [[package]]
2571
+ name = "writeable"
2572
+ version = "0.6.3"
2573
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2574
+ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
2575
+
2576
+ [[package]]
2577
+ name = "yoke"
2578
+ version = "0.8.3"
2579
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2580
+ checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
2581
+ dependencies = [
2582
+ "stable_deref_trait",
2583
+ "yoke-derive",
2584
+ "zerofrom",
2585
+ ]
2586
+
2587
+ [[package]]
2588
+ name = "yoke-derive"
2589
+ version = "0.8.2"
2590
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2591
+ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
2592
+ dependencies = [
2593
+ "proc-macro2",
2594
+ "quote",
2595
+ "syn",
2596
+ "synstructure",
2597
+ ]
2598
+
2599
+ [[package]]
2600
+ name = "zerocopy"
2601
+ version = "0.8.52"
2602
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2603
+ checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
2604
+ dependencies = [
2605
+ "zerocopy-derive",
2606
+ ]
2607
+
2608
+ [[package]]
2609
+ name = "zerocopy-derive"
2610
+ version = "0.8.52"
2611
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2612
+ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
2613
+ dependencies = [
2614
+ "proc-macro2",
2615
+ "quote",
2616
+ "syn",
2617
+ ]
2618
+
2619
+ [[package]]
2620
+ name = "zerofrom"
2621
+ version = "0.1.8"
2622
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2623
+ checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
2624
+ dependencies = [
2625
+ "zerofrom-derive",
2626
+ ]
2627
+
2628
+ [[package]]
2629
+ name = "zerofrom-derive"
2630
+ version = "0.1.7"
2631
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2632
+ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
2633
+ dependencies = [
2634
+ "proc-macro2",
2635
+ "quote",
2636
+ "syn",
2637
+ "synstructure",
2638
+ ]
2639
+
2640
+ [[package]]
2641
+ name = "zeroize"
2642
+ version = "1.8.2"
2643
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2644
+ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
2645
+
2646
+ [[package]]
2647
+ name = "zerotrie"
2648
+ version = "0.2.4"
2649
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2650
+ checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
2651
+ dependencies = [
2652
+ "displaydoc",
2653
+ "yoke",
2654
+ "zerofrom",
2655
+ ]
2656
+
2657
+ [[package]]
2658
+ name = "zerovec"
2659
+ version = "0.11.6"
2660
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2661
+ checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
2662
+ dependencies = [
2663
+ "yoke",
2664
+ "zerofrom",
2665
+ "zerovec-derive",
2666
+ ]
2667
+
2668
+ [[package]]
2669
+ name = "zerovec-derive"
2670
+ version = "0.11.3"
2671
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2672
+ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
2673
+ dependencies = [
2674
+ "proc-macro2",
2675
+ "quote",
2676
+ "syn",
2677
+ ]
2678
+
2679
+ [[package]]
2680
+ name = "zmij"
2681
+ version = "1.0.21"
2682
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2683
+ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "solverforge-fsr"
3
+ version = "2.0.7"
4
+ edition = "2021"
5
+ rust-version = "1.95"
6
+ description = "Constraint optimizer built with SolverForge"
7
+
8
+ [[bin]]
9
+ name = "solverforge_fsr"
10
+ path = "src/main.rs"
11
+
12
+ [dependencies]
13
+ solverforge = { version = "0.19.3", features = ["serde", "console", "verbose-logging"] }
14
+ solverforge-core = "0.19.3"
15
+ solverforge-ui = { version = "0.6.5" }
16
+ solverforge-maps = { version = "2.1.4" }
17
+ # Web server
18
+ axum = "0.8.9"
19
+ tokio = { version = "1.52.3", features = ["full"] }
20
+ tokio-stream = { version = "0.1.18", features = ["sync"] }
21
+ tower-http = { version = "0.6.10", features = ["fs", "cors"] }
22
+ tower = "0.5.3"
23
+
24
+ # Serialization
25
+ serde = { version = "1.0.228", features = ["derive"] }
26
+ serde_json = "1.0.149"
27
+
28
+ # Utilities
29
+ uuid = { version = "1.23.1", features = ["v4", "serde"] }
30
+ parking_lot = "0.12.5"
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build for solverforge-fsr.
2
+ #
3
+ # The app is intended to build from registry dependency declarations, so the
4
+ # repository root is the complete Docker build context:
5
+ # docker build -f Dockerfile -t solverforge-fsr .
6
+
7
+ FROM rust:1.95-alpine AS builder
8
+
9
+ RUN apk add --no-cache musl-dev
10
+
11
+ WORKDIR /build
12
+
13
+ COPY Cargo.toml Cargo.lock ./
14
+ COPY src/ ./src/
15
+ COPY static/ ./static/
16
+ COPY solver.toml ./solver.toml
17
+ COPY solverforge.app.toml ./solverforge.app.toml
18
+
19
+ RUN cargo build --release --target x86_64-unknown-linux-musl
20
+
21
+ FROM alpine:latest
22
+
23
+ RUN apk add --no-cache ca-certificates
24
+
25
+ WORKDIR /app
26
+
27
+ COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/solverforge_fsr ./solverforge_fsr
28
+ COPY --from=builder /build/static/ ./static/
29
+ COPY --from=builder /build/solver.toml ./solver.toml
30
+ COPY --from=builder /build/solverforge.app.toml ./solverforge.app.toml
31
+
32
+ ENV PORT=7860
33
+
34
+ EXPOSE 7860
35
+
36
+ CMD ["./solverforge_fsr"]
Makefile ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolverForge FSR Makefile
2
+ # Rust + frontend syntax + Space-oriented local build system.
3
+
4
+ SHELL := /bin/sh
5
+ .SHELLFLAGS := -eu -c
6
+ unexport BASH_FUNC_mc%%
7
+
8
+ GREEN := \033[92m
9
+ CYAN := \033[96m
10
+ YELLOW := \033[93m
11
+ RED := \033[91m
12
+ GRAY := \033[90m
13
+ BOLD := \033[1m
14
+ RESET := \033[0m
15
+
16
+ CHECK := OK
17
+ CROSS := FAIL
18
+ ARROW := =>
19
+ PROGRESS := ..
20
+
21
+ APP_NAME := solverforge_fsr
22
+ PACKAGE_NAME := solverforge-fsr
23
+ VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
24
+ RELEASE_TAG := $(PACKAGE_NAME)@$(VERSION)
25
+ RUST_VERSION := 1.95+
26
+ PORT ?= 7860
27
+ DOCKER_IMAGE ?= $(PACKAGE_NAME)
28
+ DOCKER_CONTEXT ?= .
29
+ DOCKERFILE_PATH := Dockerfile
30
+ PLAYWRIGHT ?= ../node_modules/.bin/playwright
31
+
32
+ .PHONY: help doctor build build-release run run-release test test-rust \
33
+ test-frontend-syntax test-e2e lint fmt fmt-check clippy check ci-local \
34
+ space-ci space-build space-run docker-build docker-run pre-release \
35
+ release-ci release-info version clean watch require-node require-docker
36
+
37
+ .DEFAULT_GOAL := help
38
+
39
+ require-node:
40
+ @command -v node >/dev/null 2>&1 || (printf "$(RED)$(CROSS) node is required for frontend validation$(RESET)\n" && exit 1)
41
+
42
+ require-docker:
43
+ @command -v docker >/dev/null 2>&1 || (printf "$(RED)$(CROSS) docker is required for Space/Docker targets$(RESET)\n" && exit 1)
44
+
45
+ doctor:
46
+ @printf "$(CYAN)$(BOLD)Environment Check$(RESET)\n\n"
47
+ @missing=0; \
48
+ if command -v cargo >/dev/null 2>&1; then \
49
+ printf "$(GREEN)$(CHECK) cargo: $$(cargo --version)$(RESET)\n"; \
50
+ else \
51
+ printf "$(RED)$(CROSS) cargo not found$(RESET)\n"; missing=1; \
52
+ fi; \
53
+ if command -v rustc >/dev/null 2>&1; then \
54
+ printf "$(GREEN)$(CHECK) rustc: $$(rustc --version)$(RESET)\n"; \
55
+ else \
56
+ printf "$(RED)$(CROSS) rustc not found$(RESET)\n"; missing=1; \
57
+ fi; \
58
+ if command -v node >/dev/null 2>&1; then \
59
+ printf "$(GREEN)$(CHECK) node: $$(node --version)$(RESET)\n"; \
60
+ else \
61
+ printf "$(YELLOW)! node not found; frontend syntax validation will be unavailable$(RESET)\n"; \
62
+ fi; \
63
+ if command -v docker >/dev/null 2>&1; then \
64
+ printf "$(GREEN)$(CHECK) docker: $$(docker --version)$(RESET)\n"; \
65
+ else \
66
+ printf "$(YELLOW)! docker not found; Space/Docker targets will be unavailable$(RESET)\n"; \
67
+ fi; \
68
+ printf "$(GRAY)Docker build context: $(DOCKER_CONTEXT)$(RESET)\n"; \
69
+ printf "$(GRAY)Default app port: $(PORT)$(RESET)\n"; \
70
+ if [ $$missing -ne 0 ]; then exit 1; fi
71
+
72
+ build:
73
+ @printf "$(ARROW) Building $(PACKAGE_NAME)...\n"
74
+ @cargo build --bin $(APP_NAME)
75
+
76
+ build-release:
77
+ @printf "$(ARROW) Building release binary...\n"
78
+ @cargo build --release --bin $(APP_NAME)
79
+
80
+ run:
81
+ @printf "$(ARROW) Running $(PACKAGE_NAME) on port $(PORT)...\n"
82
+ @PORT=$(PORT) cargo run --bin $(APP_NAME)
83
+
84
+ run-release:
85
+ @printf "$(ARROW) Running release build on port $(PORT)...\n"
86
+ @PORT=$(PORT) cargo run --release --bin $(APP_NAME)
87
+
88
+ test: test-rust test-frontend-syntax test-e2e
89
+ @printf "\n$(GREEN)$(BOLD)$(CHECK) Standard validation passed$(RESET)\n\n"
90
+
91
+ test-rust:
92
+ @printf "$(PROGRESS) Running cargo test --quiet...\n"
93
+ @cargo test --quiet
94
+
95
+ test-frontend-syntax: require-node
96
+ @printf "$(PROGRESS) Checking frontend module syntax...\n"
97
+ @find static -name '*.js' -print0 | xargs -0 -n1 node --check
98
+
99
+ test-e2e: build-release require-node
100
+ @printf "$(PROGRESS) Running Playwright browser tests...\n"
101
+ @$(PLAYWRIGHT) test --config tests/e2e/playwright.config.js
102
+
103
+ fmt:
104
+ @printf "$(PROGRESS) Formatting Rust code...\n"
105
+ @cargo fmt
106
+
107
+ fmt-check:
108
+ @printf "$(PROGRESS) Checking Rust formatting...\n"
109
+ @cargo fmt --check
110
+
111
+ clippy:
112
+ @printf "$(PROGRESS) Running clippy...\n"
113
+ @cargo clippy --all-targets -- -D warnings
114
+
115
+ lint: fmt-check clippy test-frontend-syntax
116
+ @printf "\n$(GREEN)$(BOLD)$(CHECK) Lint checks passed$(RESET)\n\n"
117
+
118
+ check: lint test
119
+
120
+ docker-build: require-docker
121
+ @printf "$(PROGRESS) Building Docker image $(DOCKER_IMAGE)...\n"
122
+ @docker build -f "$(DOCKERFILE_PATH)" -t "$(DOCKER_IMAGE)" "$(DOCKER_CONTEXT)"
123
+
124
+ docker-run: require-docker
125
+ @printf "$(ARROW) Running $(DOCKER_IMAGE) on port $(PORT)...\n"
126
+ @docker run --rm -it -e PORT=$(PORT) -p $(PORT):$(PORT) "$(DOCKER_IMAGE)"
127
+
128
+ space-build: docker-build
129
+
130
+ space-run: space-build
131
+ @printf "$(GREEN)$(CHECK) Starting local container that mirrors the Space image$(RESET)\n"
132
+ @$(MAKE) docker-run --no-print-directory PORT=$(PORT) DOCKER_IMAGE=$(DOCKER_IMAGE)
133
+
134
+ space-ci: ci-local
135
+
136
+ ci-local:
137
+ @printf "$(CYAN)$(BOLD)Local Space Validation Pipeline$(RESET)\n\n"
138
+ @printf "$(PROGRESS) Step 1/5: Format check...\n"
139
+ @$(MAKE) fmt-check --no-print-directory
140
+ @printf "$(PROGRESS) Step 2/5: Clippy...\n"
141
+ @$(MAKE) clippy --no-print-directory
142
+ @printf "$(PROGRESS) Step 3/5: Release build...\n"
143
+ @$(MAKE) build-release --no-print-directory
144
+ @printf "$(PROGRESS) Step 4/5: Standard test surface...\n"
145
+ @$(MAKE) test --no-print-directory
146
+ @printf "$(PROGRESS) Step 5/5: Docker/Space image build...\n"
147
+ @$(MAKE) space-build --no-print-directory
148
+ @printf "\n$(GREEN)$(BOLD)$(CHECK) LOCAL SPACE VALIDATION PASSED$(RESET)\n\n"
149
+
150
+ pre-release: ci-local
151
+ @printf "$(GREEN)$(BOLD)$(CHECK) Ready for Hugging Face Space update$(RESET)\n\n"
152
+
153
+ release-ci: ci-local
154
+ @printf "$(GREEN)$(BOLD)$(CHECK) Release CI passed for $(RELEASE_TAG)$(RESET)\n\n"
155
+
156
+ release-info:
157
+ @printf "$(CYAN)Package:$(RESET) $(YELLOW)$(BOLD)$(PACKAGE_NAME)$(RESET)\n"
158
+ @printf "$(CYAN)Version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n"
159
+ @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n"
160
+
161
+ version:
162
+ @printf "$(CYAN)Current version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n"
163
+ @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n"
164
+ @printf "$(CYAN)Default port:$(RESET) $(YELLOW)$(BOLD)$(PORT)$(RESET)\n"
165
+
166
+ clean:
167
+ @printf "$(ARROW) Cleaning build artifacts...\n"
168
+ @cargo clean
169
+
170
+ watch:
171
+ @printf "$(ARROW) Watching and rerunning the app on port $(PORT)...\n"
172
+ @cargo watch --version >/dev/null 2>&1 || \
173
+ (printf "$(RED)$(CROSS) cargo-watch is required for make watch$(RESET)\n" && exit 1)
174
+ @PORT=$(PORT) cargo watch -x "run --bin $(APP_NAME)"
175
+
176
+ help:
177
+ @/bin/echo -e "$(CYAN)$(BOLD)Build & Run:$(RESET)"
178
+ @/bin/echo -e " $(GREEN)make build$(RESET) - Build the app in debug mode"
179
+ @/bin/echo -e " $(GREEN)make build-release$(RESET) - Build the app in release mode"
180
+ @/bin/echo -e " $(GREEN)make run$(RESET) - Run locally on port $(PORT)"
181
+ @/bin/echo -e " $(GREEN)make run-release$(RESET) - Run the release build on port $(PORT)"
182
+ @/bin/echo -e ""
183
+ @/bin/echo -e "$(CYAN)$(BOLD)Tests & Validation:$(RESET)"
184
+ @/bin/echo -e " $(GREEN)make test$(RESET) - Run Rust, frontend syntax, and Playwright checks"
185
+ @/bin/echo -e " $(GREEN)make test-e2e$(RESET) - Run Playwright browser tests"
186
+ @/bin/echo -e " $(GREEN)make lint$(RESET) - Run fmt-check, clippy, and frontend syntax checks"
187
+ @/bin/echo -e " $(GREEN)make ci-local$(RESET) - Run local Space validation pipeline"
188
+ @/bin/echo -e " $(GREEN)make release-ci$(RESET) - Run the tag-publish CI gate for this app"
189
+ @/bin/echo -e " $(GREEN)make pre-release$(RESET) - Run all local Space readiness checks"
190
+ @/bin/echo -e ""
191
+ @/bin/echo -e "$(CYAN)$(BOLD)Space & Docker:$(RESET)"
192
+ @/bin/echo -e " $(GREEN)make space-build$(RESET) - Build the Docker image used for Space deployment"
193
+ @/bin/echo -e " $(GREEN)make space-run$(RESET) - Build and run that image locally on port $(PORT)"
194
+ @/bin/echo -e " $(GREEN)make docker-build$(RESET) - Build the Docker image directly"
195
+ @/bin/echo -e " $(GREEN)make docker-run$(RESET) - Run the Docker image directly"
196
+ @/bin/echo -e ""
197
+ @/bin/echo -e "$(CYAN)$(BOLD)Other:$(RESET)"
198
+ @/bin/echo -e " $(GREEN)make doctor$(RESET) - Check local cargo/rustc/node readiness"
199
+ @/bin/echo -e " $(GREEN)make fmt$(RESET) - Format Rust code"
200
+ @/bin/echo -e " $(GREEN)make release-info$(RESET) - Show package version and app-scoped release tag"
201
+ @/bin/echo -e " $(GREEN)make version$(RESET) - Show version and default port"
202
+ @/bin/echo -e " $(GREEN)make clean$(RESET) - Clean build artifacts"
203
+ @/bin/echo -e " $(GREEN)make watch$(RESET) - Watch source files and rerun the app"
204
+ @/bin/echo -e ""
205
+ @/bin/echo -e "$(GRAY)Rust version required: $(RUST_VERSION)$(RESET)"
206
+ @/bin/echo -e "$(GRAY)Current version: v$(VERSION)$(RESET)"
207
+ @/bin/echo -e "$(GRAY)Release tag: $(RELEASE_TAG)$(RESET)"
README.md ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: SolverForge Field Service Routing
3
+ emoji: 🧰
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: apache-2.0
10
+ short_description: SolverForge field-service routing example
11
+ ---
12
+
13
+ # SolverForge FSR
14
+
15
+ ![SolverForge FSR screenshot](docs/screenshot.png)
16
+
17
+ `solverforge-fsr` is a SolverForge field-service routing app with retained
18
+ jobs, technician schedules, road-network geometry, and a browser map workspace.
19
+
20
+ It answers one concrete question:
21
+
22
+ "Given technicians, service visits, skills, parts, shifts, territories, and
23
+ road-network travel, which technician should serve each visit and in what
24
+ order?"
25
+
26
+ ## Quick Start
27
+
28
+ ```sh
29
+ make run-release
30
+ ```
31
+
32
+ Then open `http://localhost:7860`.
33
+
34
+ To inspect the supported command surface:
35
+
36
+ ```sh
37
+ make help
38
+ ```
39
+
40
+ ## Documentation Map
41
+
42
+ - `README.md`
43
+ Quick start, model concepts, validation, REST API, and solver policy.
44
+ - `WIREFRAME.md`
45
+ As-built architecture and runtime/data flow across backend, routing, and UI.
46
+ - `AGENTS.md`
47
+ Codex-facing maintenance, validation, and documentation rules.
48
+ - `Makefile`
49
+ Supported local commands for development, validation, Docker, and Space work.
50
+ - `Dockerfile`
51
+ Docker Space image build using Rust 1.95 and the declared crates.io line.
52
+
53
+ ## Current Dependency Shape
54
+
55
+ - Package: `solverforge-fsr`; version is declared in `Cargo.toml`
56
+ - Release binary: `solverforge_fsr`
57
+ - Rust: `1.95`
58
+ - SolverForge runtime: `solverforge` `0.19.3`
59
+ - SolverForge core helpers: `solverforge-core` `0.19.3`
60
+ - Browser UI assets: `solverforge-ui` `0.6.5`
61
+ - Routing engine: `solverforge-maps` `2.1.4`
62
+ - Scaffold metadata: `solverforge-cli` `2.2.2` in `solverforge.app.toml`
63
+
64
+ The app serves registry-backed Rust dependencies, local static browser modules,
65
+ and Axum API routes from one process.
66
+
67
+ ## Model Concepts
68
+
69
+ - `Location` is a problem fact: a depot or customer coordinate.
70
+ - `ServiceVisit` is a problem fact: a customer job the solver must place in a
71
+ route.
72
+ - `TravelLeg` is a problem fact: precomputed duration, distance, and
73
+ reachability between two locations.
74
+ - `TechnicianRoute` is the planning entity: one route owned by one technician.
75
+ - `TechnicianRoute.visits` is the list planning variable: the ordered visit
76
+ sequence SolverForge changes.
77
+ - `FieldServicePlan` is the planning solution with the current `HardSoftScore`.
78
+
79
+ The app ships one deterministic `STANDARD` Bergamo dataset with two depots, six
80
+ technicians, 24 customer locations, and 48 service visits.
81
+
82
+ ## Constraints
83
+
84
+ Hard constraints:
85
+
86
+ - Every service visit is assigned exactly once, and route visit indexes are valid.
87
+ - Every route leg is reachable.
88
+ - The assigned technician has the required skills.
89
+ - The assigned technician carries the required parts.
90
+ - Visits fit their time windows.
91
+ - Routes fit technician shift capacity.
92
+
93
+ Soft constraints:
94
+
95
+ - Total travel time is minimized.
96
+ - Workload is balanced across technicians.
97
+ - Territory affinity is preferred.
98
+ - Deadline slack is rewarded more strongly for higher-priority visits.
99
+
100
+ ## REST API
101
+
102
+ - `GET /health`
103
+ - `GET /info`
104
+ - `GET /demo-data`
105
+ - `GET /demo-data/{id}`
106
+ - `POST /jobs`
107
+ - `GET /jobs/{id}`
108
+ - `DELETE /jobs/{id}`
109
+ - `GET /jobs/{id}/status`
110
+ - `GET /jobs/{id}/snapshot`
111
+ - `GET /jobs/{id}/analysis`
112
+ - `GET /jobs/{id}/routes`
113
+ - `POST /jobs/{id}/pause`
114
+ - `POST /jobs/{id}/resume`
115
+ - `POST /jobs/{id}/cancel`
116
+ - `GET /jobs/{id}/events`
117
+
118
+ `snapshot_revision={n}` is optional for snapshots, analysis, and route
119
+ geometry. Route geometry reports unreachable, snap-failed, and no-path legs as
120
+ segment statuses so one failed road leg does not hide the rest of the route.
121
+
122
+ ## Solver Policy
123
+
124
+ `solver.toml` is embedded by `FieldServicePlan` and is the runtime source of
125
+ truth.
126
+
127
+ - `list_round_robin` creates the first visit distribution.
128
+ - Local search combines list change, list swap, sublist change, sublist swap,
129
+ and reverse moves over `TechnicianRoute.visits`.
130
+ - `hill_climbing` with `first_best_score_improving` keeps this tutorial easy to
131
+ reason about.
132
+ - Solving stops after 60 seconds.
133
+
134
+ Road-network routing is prepared from the deterministic Bergamo coordinates and
135
+ stored as `TravelLeg` facts before solving.
136
+
137
+ ## Validation
138
+
139
+ Standard validation:
140
+
141
+ ```sh
142
+ make test
143
+ ```
144
+
145
+ Full local validation:
146
+
147
+ ```sh
148
+ make ci-local
149
+ ```
150
+
151
+ `make test` runs Rust tests, JavaScript syntax checks, and Playwright browser
152
+ tests. `make ci-local` adds formatting, clippy, release build, and Docker image
153
+ build.
154
+
155
+ ## Hugging Face Space Deployment
156
+
157
+ This repo is Docker-Space ready. The Space reads the README front matter,
158
+ builds `Dockerfile`, and expects the app to bind `PORT=7860`.
159
+
160
+ Local Space-equivalent commands:
161
+
162
+ ```sh
163
+ make space-build
164
+ make space-run
165
+ ```
166
+
167
+ ## Read The Code In This Order
168
+
169
+ 1. `src/domain/mod.rs`
170
+ The `planning_model!` manifest and public domain exports.
171
+ 2. `src/domain/field_service_plan.rs`
172
+ The solution type, fact collections, route entities, transient index
173
+ normalization, route shadow refresh, and score.
174
+ 3. `src/domain/location.rs`, `src/domain/service_visit.rs`, and
175
+ `src/domain/travel_leg.rs`
176
+ The problem facts the solver reads.
177
+ 4. `src/domain/technician_route.rs` and `src/domain/route_metrics.rs`
178
+ The planning entity, list variable SolverForge mutates, and route shadow
179
+ measurements used by stock constraints.
180
+ 5. `src/data/data_seed.rs` and `src/data/bergamo_*.rs`
181
+ Demo ID, Bergamo data assembly, static fact catalogs, routing preparation,
182
+ and cache policy.
183
+ 6. `src/constraints/mod.rs`
184
+ The score model assembled from SolverForge constraints.
185
+ 7. `src/constraints/*.rs`
186
+ One business scoring rule per file. Most rules use stock `ConstraintFactory`
187
+ streams; duplicate visit assignment uses a custom incremental counter so
188
+ retained score analysis counts only real duplicate groups.
189
+ 8. `src/solver/service.rs`
190
+ Retained-job orchestration over `SolverManager<FieldServicePlan>`.
191
+ 9. `src/api/routes.rs`, `src/api/dto.rs`, `src/api/route_geometry.rs`, and
192
+ `src/api/sse.rs`
193
+ HTTP routes, transport DTOs, route geometry, and live-event streaming.
194
+ 10. `static/app.js` and `static/app-*.js`
195
+ Browser lifecycle, dataset loading, route rendering, maps, tables, and API
196
+ guide.
197
+
198
+ ## Project Shape
199
+
200
+ - `src/domain/`
201
+ Planning model, domain types, route entities, and route shadow measurements.
202
+ - `src/constraints/`
203
+ SolverForge scoring rules, one business rule per file; most use stock streams.
204
+ - `src/data/`
205
+ Deterministic Bergamo demo data and road-network preparation.
206
+ - `src/solver/`
207
+ Retained-job facade and runtime event payload formatting.
208
+ - `src/api/`
209
+ Axum routes, DTOs, route geometry, and SSE endpoint.
210
+ - `static/`
211
+ Browser workspace built on stock `solverforge-ui` assets.
212
+ - `tests/e2e/`
213
+ Playwright browser tests for the served app.
WIREFRAME.md ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # solverforge-fsr WIREFRAME
2
+
3
+ This file is the architectural map for the field-service routing example.
4
+
5
+ `README.md` explains how to run and use the app. This document explains how the
6
+ pieces fit together and where each responsibility lives.
7
+
8
+ ## Documentation Roles
9
+
10
+ - `README.md`
11
+ Quick start, dependency shape, API list, and user-facing orientation.
12
+ - `WIREFRAME.md`
13
+ Architecture, execution flow, and file-map walkthrough.
14
+ - `AGENTS.md`
15
+ Repo-specific contribution, validation, and documentation rules.
16
+ - `Makefile`
17
+ Local development, validation, and Space/Docker command surface.
18
+ - `Dockerfile`
19
+ Hugging Face Docker Space image definition.
20
+
21
+ ## What This Repo Is Teaching
22
+
23
+ This repo is a complete `solverforge-fsr` list-variable SolverForge app for
24
+ field-service routing in Bergamo.
25
+
26
+ It shows how to combine:
27
+
28
+ - a `FieldServicePlan` solution with a list planning variable
29
+ - route-level hard and soft score rules
30
+ - precomputed travel-leg facts and `solverforge-maps` road-network geometry
31
+ - retained jobs with snapshots, analysis, cancel, pause, resume, and SSE
32
+ - a browser map workspace built on stock `solverforge-ui` assets
33
+
34
+ ## SolverForge Concepts In Plain Language
35
+
36
+ - `Location`
37
+ Input place data. Depots and customer sites are indexed so routes can refer to
38
+ them cheaply.
39
+ - `ServiceVisit`
40
+ Input job data. The solver places visit indexes into technician routes.
41
+ - `TravelLeg`
42
+ Input travel data. Each leg records duration, distance, and whether the road
43
+ graph can connect the two locations.
44
+ - `TechnicianRoute`
45
+ Planning entity. Each technician owns one ordered `visits` list.
46
+ - `FieldServicePlan`
47
+ Planning solution. It holds facts, route entities, and the current score.
48
+ - hard score
49
+ Missing visits, duplicate visits, invalid visit indexes, unreachable legs,
50
+ missing skills or parts, late visits, and route overtime.
51
+ - soft score
52
+ Travel cost, workload balance, territory fit, and priority slack.
53
+ - retained job
54
+ A solve that lives in memory so the UI can stream events, fetch snapshots,
55
+ pause/resume, cancel, analyze, and delete terminal jobs.
56
+
57
+ ## Runtime Flow
58
+
59
+ 1. The browser loads `static/index.html`.
60
+ 2. `static/app.js` loads `static/sf-config.json` and
61
+ `static/generated/ui-model.json`.
62
+ 3. The app fetches `/demo-data/STANDARD`.
63
+ 4. The backend returns a `FieldServicePlan` with seed travel legs.
64
+ 5. The browser renders route cards, tables, timeline, map shell, and the visible
65
+ REST API guide.
66
+ 6. When the user clicks Solve, the browser posts the current plan to
67
+ `POST /jobs`.
68
+ 7. `src/api/routes.rs` deserializes the `PlanDto`; domain deserialization and
69
+ `FieldServicePlan::normalize()` restore transient service-visit indexes and
70
+ route shadow fields before routing preparation.
71
+ 8. `prepare_routing()` loads or fetches the Bergamo road network, computes the
72
+ full travel matrix, and replaces seed legs with road-network legs.
73
+ 9. `SolverService` starts a retained solve through
74
+ `SolverManager<FieldServicePlan>`.
75
+ 10. Solver events are converted by `src/solver/event_payload.rs` into
76
+ UI-facing JSON.
77
+ 11. The browser consumes `/jobs/{id}/events` and fetches snapshots, analysis,
78
+ and route geometry for exact snapshot revisions.
79
+ 12. `src/api/route_geometry.rs` builds map segments, preserving non-routed
80
+ statuses so one unreachable leg does not hide the rest of a route.
81
+
82
+ ## File Map
83
+
84
+ ```text
85
+ .
86
+ ├── Cargo.toml
87
+ │ Rust 1.95 crate metadata for the app package and registry dependency
88
+ │ requests.
89
+ ├── solver.toml
90
+ │ Embedded search policy for list construction and local search.
91
+ ├── solverforge.app.toml
92
+ │ App metadata, demo IDs, model facts/entities, registry dependency sources,
93
+ │ and the `solverforge 0.19.3` runtime target.
94
+ ├── Makefile
95
+ │ Local build, validation, and Space/Docker commands.
96
+ ├── Dockerfile
97
+ │ Multi-stage Rust 1.95 Docker image for Hugging Face Spaces.
98
+ ├── README.md
99
+ │ Run guide, dependency shape, API list, and learning path.
100
+ ├── AGENTS.md
101
+ │ Repo-specific rules for future edits.
102
+ ├── WIREFRAME.md
103
+ │ This architectural walkthrough.
104
+ ├── docs/screenshot.png
105
+ │ Current browser screenshot used by the README.
106
+ ├── src/
107
+ │ ├── domain/
108
+ │ │ `planning_model!` manifest, facts, route entity, shadows, and solution.
109
+ │ ├── constraints/
110
+ │ │ Mostly stock SolverForge streams plus the custom duplicate-assignment
111
+ │ │ counter in `assigned_visits.rs`.
112
+ │ ├── data/
113
+ │ │ Deterministic Bergamo seeds, demo entrypoints, and OSM matrix loading.
114
+ │ ├── solver/
115
+ │ │ Retained-job service and runtime event payload formatting.
116
+ │ └── api/
117
+ │ Axum routes, DTOs, route geometry, and SSE endpoint.
118
+ └── static/
119
+ ├── index.html
120
+ ├── sf-config.json
121
+ ├── generated/ui-model.json
122
+ └── app*.js
123
+ Browser controller, map rendering, route state, layout, and tables.
124
+ ```
125
+
126
+ ## Domain And Route Metrics
127
+
128
+ `src/domain/field_service_plan.rs` owns the public solution shape. It keeps the
129
+ SolverForge model explicit: facts are read-only inputs, while
130
+ `TechnicianRoute.visits` is the one mutable list variable. Its custom
131
+ deserializer and `normalize()` method rebuild skipped transient visit indexes
132
+ and route shadow values after JSON round trips, direct deserialization, and
133
+ seed travel-matrix replacement.
134
+
135
+ Route-specific scoring math lives in `src/domain/route_metrics.rs`. That module
136
+ walks a route from depot to visits to depot, advances a service clock, and
137
+ records reusable counters as `TechnicianRoute` shadow values. The constraint
138
+ files then use stock SolverForge `ConstraintFactory` streams over those shadow
139
+ fields. The assignment module uses stock streams for missing visits and invalid
140
+ route visit indexes; duplicate assignments use a small custom
141
+ `IncrementalConstraint` so `/jobs/{id}/analysis` reports matches only for visit
142
+ indexes assigned more than once while scoring each extra valid assignment.
143
+
144
+ `src/api/route_geometry.rs` is separate because map drawing has a different
145
+ job from scoring. Scoring consumes matrix facts already on the plan; geometry
146
+ loads the road graph to draw visible polylines for a retained snapshot.
147
+
148
+ ## Demo Data
149
+
150
+ `src/data/data_seed.rs` exposes one demo ID:
151
+
152
+ - `STANDARD`
153
+
154
+ The generator is deterministic. It builds two depots, 24 customer locations, 48
155
+ visits, six technicians, and seed self-leg travel facts. Full road-network
156
+ travel facts are prepared when a job is created.
157
+
158
+ ## API And Retained Runtime
159
+
160
+ The REST API handles discovery, job control, snapshots, and route geometry:
161
+
162
+ - `/health` and `/info` expose liveness and app metadata.
163
+ - `/demo-data` and `/demo-data/{id}` expose the deterministic demo catalog.
164
+ - `/jobs` creates a retained solver job.
165
+ - `/jobs/{id}` and `/jobs/{id}/status` expose summary state.
166
+ - `/jobs/{id}/snapshot` returns an exact or latest snapshot.
167
+ - `/jobs/{id}/analysis` runs constraint analysis for a snapshot.
168
+ - `/jobs/{id}/routes` returns route geometry for a snapshot.
169
+ - `/jobs/{id}/pause`, `/jobs/{id}/resume`, and `/jobs/{id}/cancel` control a
170
+ live job.
171
+ - `DELETE /jobs/{id}` removes a terminal retained job.
172
+ - `/jobs/{id}/events` streams typed lifecycle events.
173
+
174
+ ## Frontend Layout
175
+
176
+ `static/app.js` is the controller. It owns current plan state, retained job
177
+ state, route focus, event handlers, and analysis modal wiring.
178
+
179
+ Supporting modules split the UI by responsibility:
180
+
181
+ - `static/app-dataset.js`
182
+ Demo catalog and plan loading.
183
+ - `static/app-layout.js`
184
+ Page shell and stock SolverForge UI component composition.
185
+ - `static/app-route-state.js`
186
+ Snapshot identity and route geometry cache coordination.
187
+ - `static/app-render*.js`
188
+ Summary cards, route cards, maps, timeline, tables, and API guide.
189
+ - `static/app-utils.js`
190
+ Plan cloning, labels, formatting, and color helpers.
191
+
192
+ ## Validation Surfaces
193
+
194
+ Use the Makefile as the repo-local workflow:
195
+
196
+ - `make fmt-check`
197
+ - `make clippy`
198
+ - `make build-release`
199
+ - `make test`
200
+ - `make test-e2e`
201
+ - `make space-build`
202
+ - `make ci-local`
203
+ - `make pre-release`
204
+
205
+ `make ci-local` includes the Docker image build used by the Hugging Face Space.
206
+ The Playwright command uses the publication bundle's root Node dev dependency;
207
+ runtime UI assets are served from the declared `solverforge-ui` Cargo crate.
docs/screenshot.png ADDED

Git LFS Details

  • SHA256: f7bc17e1f1984c781bb6da623283f703dc782aa7fea7bd6d074ad606b11c7114
  • Pointer size: 130 Bytes
  • Size of remote file: 93.9 kB
solver.toml ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [[phases]]
2
+ type = "construction_heuristic"
3
+ construction_heuristic_type = "list_round_robin"
4
+
5
+ [[phases]]
6
+ type = "local_search"
7
+
8
+ [phases.acceptor]
9
+ type = "hill_climbing"
10
+
11
+ [phases.forager]
12
+ type = "first_best_score_improving"
13
+
14
+ [phases.move_selector]
15
+ type = "union_move_selector"
16
+ selection_order = "round_robin"
17
+
18
+ [[phases.move_selector.selectors]]
19
+ type = "list_change_move_selector"
20
+ entity_class = "TechnicianRoute"
21
+ variable_name = "visits"
22
+
23
+ [[phases.move_selector.selectors]]
24
+ type = "list_swap_move_selector"
25
+ entity_class = "TechnicianRoute"
26
+ variable_name = "visits"
27
+
28
+ [[phases.move_selector.selectors]]
29
+ type = "sublist_change_move_selector"
30
+ min_sublist_size = 1
31
+ max_sublist_size = 3
32
+ entity_class = "TechnicianRoute"
33
+ variable_name = "visits"
34
+
35
+ [[phases.move_selector.selectors]]
36
+ type = "sublist_swap_move_selector"
37
+ min_sublist_size = 1
38
+ max_sublist_size = 3
39
+ entity_class = "TechnicianRoute"
40
+ variable_name = "visits"
41
+
42
+ [[phases.move_selector.selectors]]
43
+ type = "list_reverse_move_selector"
44
+ entity_class = "TechnicianRoute"
45
+ variable_name = "visits"
46
+
47
+ [termination]
48
+ seconds_spent_limit = 60
solverforge.app.toml ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [app]
2
+ name = "solverforge-fsr"
3
+ starter = "neutral-shell"
4
+ cli_version = "2.2.2"
5
+ shell = "web"
6
+
7
+ [runtime]
8
+ target = "solverforge 0.19.3"
9
+ runtime_source = "crates.io: solverforge 0.19.3"
10
+ ui_source = "crates.io: solverforge-ui 0.6.5"
11
+
12
+ [demo]
13
+ default_size = "STANDARD"
14
+ available_sizes = [
15
+ "STANDARD",
16
+ ]
17
+
18
+ [solution]
19
+ name = "FieldServicePlan"
20
+ score = "HardSoftScore"
21
+
22
+ [[facts]]
23
+ name = "location"
24
+ plural = "locations"
25
+ kind = "problem_fact"
26
+
27
+ [[facts]]
28
+ name = "service_visit"
29
+ plural = "service_visits"
30
+ kind = "problem_fact"
31
+
32
+ [[facts]]
33
+ name = "travel_leg"
34
+ plural = "travel_legs"
35
+ kind = "problem_fact"
36
+
37
+ [[entities]]
38
+ name = "technician_route"
39
+ plural = "technician_routes"
40
+ kind = "planning_entity"
41
+
42
+ [[variables]]
43
+ entity = "technician_route"
44
+ entity_plural = "technician_routes"
45
+ field = "visits"
46
+ kind = "list"
47
+ range = ""
48
+ elements = "service_visits"
49
+ allows_unassigned = false
50
+ enabled = true
51
+
52
+ [[constraints]]
53
+ name = "assigned_visits"
54
+ module = "assigned_visits"
55
+ enabled = true
56
+
57
+ [[constraints]]
58
+ name = "balance_workload"
59
+ module = "balance_workload"
60
+ enabled = true
61
+
62
+ [[constraints]]
63
+ name = "minimize_travel"
64
+ module = "minimize_travel"
65
+ enabled = true
66
+
67
+ [[constraints]]
68
+ name = "priority_slack"
69
+ module = "priority_slack"
70
+ enabled = true
71
+
72
+ [[constraints]]
73
+ name = "reachable_legs"
74
+ module = "reachable_legs"
75
+ enabled = true
76
+
77
+ [[constraints]]
78
+ name = "required_parts"
79
+ module = "required_parts"
80
+ enabled = true
81
+
82
+ [[constraints]]
83
+ name = "required_skills"
84
+ module = "required_skills"
85
+ enabled = true
86
+
87
+ [[constraints]]
88
+ name = "shift_capacity"
89
+ module = "shift_capacity"
90
+ enabled = true
91
+
92
+ [[constraints]]
93
+ name = "territory_affinity"
94
+ module = "territory_affinity"
95
+ enabled = true
96
+
97
+ [[constraints]]
98
+ name = "time_windows"
99
+ module = "time_windows"
100
+ enabled = true
src/api/dto.rs ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Browser-facing JSON types for FSR retained jobs.
2
+ //!
3
+ //! The domain model is optimized for SolverForge joins and score calculation.
4
+ //! DTOs keep the HTTP contract stable and browser-friendly, including string
5
+ //! score labels and camelCase field names.
6
+
7
+ use serde::{Deserialize, Serialize};
8
+ use serde_json::{Map, Value};
9
+ use solverforge::{
10
+ HardSoftScore, SolverLifecycleState, SolverSnapshot, SolverSnapshotAnalysis, SolverStatus,
11
+ SolverTelemetry, SolverTerminalReason,
12
+ };
13
+ use std::time::Duration;
14
+
15
+ use crate::domain::FieldServicePlan;
16
+
17
+ #[derive(Debug, Clone, Serialize, Deserialize)]
18
+ #[serde(rename_all = "camelCase")]
19
+ pub struct PlanDto {
20
+ /// Flattened domain fields let the stock UI metadata describe facts and
21
+ /// entities without a hand-written transport struct for every collection.
22
+ #[serde(flatten)]
23
+ pub fields: Map<String, Value>,
24
+ #[serde(default)]
25
+ pub score: Option<String>,
26
+ }
27
+
28
+ /// Constraint analysis result.
29
+ #[derive(Debug, Clone, Serialize)]
30
+ #[serde(rename_all = "camelCase")]
31
+ pub struct ConstraintAnalysisDto {
32
+ pub name: String,
33
+ pub weight: String,
34
+ pub score: String,
35
+ pub match_count: usize,
36
+ }
37
+
38
+ #[derive(Debug, Clone, Serialize)]
39
+ #[serde(rename_all = "camelCase")]
40
+ pub struct AnalyzeResponse {
41
+ pub score: String,
42
+ pub constraints: Vec<ConstraintAnalysisDto>,
43
+ }
44
+
45
+ #[derive(Debug, Clone, Copy, Serialize)]
46
+ #[serde(rename_all = "camelCase")]
47
+ pub struct TelemetryDto {
48
+ pub elapsed_ms: u64,
49
+ pub step_count: u64,
50
+ pub moves_generated: u64,
51
+ pub moves_evaluated: u64,
52
+ pub moves_accepted: u64,
53
+ pub score_calculations: u64,
54
+ pub generation_ms: u64,
55
+ pub evaluation_ms: u64,
56
+ pub moves_per_second: u64,
57
+ pub acceptance_rate: f64,
58
+ }
59
+
60
+ #[derive(Debug, Clone, Serialize)]
61
+ #[serde(rename_all = "camelCase")]
62
+ pub struct JobSummaryDto {
63
+ pub id: String,
64
+ pub job_id: String,
65
+ pub lifecycle_state: &'static str,
66
+ pub terminal_reason: Option<&'static str>,
67
+ pub checkpoint_available: bool,
68
+ pub event_sequence: u64,
69
+ pub snapshot_revision: Option<u64>,
70
+ pub current_score: Option<String>,
71
+ pub best_score: Option<String>,
72
+ pub telemetry: TelemetryDto,
73
+ }
74
+
75
+ #[derive(Debug, Clone, Serialize)]
76
+ #[serde(rename_all = "camelCase")]
77
+ pub struct JobSnapshotDto {
78
+ pub id: String,
79
+ pub job_id: String,
80
+ pub snapshot_revision: u64,
81
+ pub lifecycle_state: &'static str,
82
+ pub terminal_reason: Option<&'static str>,
83
+ pub current_score: Option<String>,
84
+ pub best_score: Option<String>,
85
+ pub telemetry: TelemetryDto,
86
+ pub solution: PlanDto,
87
+ }
88
+
89
+ #[derive(Debug, Clone, Serialize)]
90
+ #[serde(rename_all = "camelCase")]
91
+ pub struct JobAnalysisDto {
92
+ pub id: String,
93
+ pub job_id: String,
94
+ pub snapshot_revision: u64,
95
+ pub lifecycle_state: &'static str,
96
+ pub terminal_reason: Option<&'static str>,
97
+ pub analysis: AnalyzeResponse,
98
+ }
99
+
100
+ impl PlanDto {
101
+ pub fn from_plan(plan: &FieldServicePlan) -> Self {
102
+ let mut fields = match serde_json::to_value(plan).expect("failed to serialize plan") {
103
+ Value::Object(map) => map,
104
+ _ => Map::new(),
105
+ };
106
+ let score = fields.remove("score").and_then(|value| {
107
+ if value.is_null() {
108
+ None
109
+ } else if let Some(score) = value.as_str() {
110
+ Some(score.to_string())
111
+ } else {
112
+ Some(value.to_string())
113
+ }
114
+ });
115
+
116
+ Self { fields, score }
117
+ }
118
+
119
+ pub fn to_domain(&self) -> Result<FieldServicePlan, serde_json::Error> {
120
+ let mut fields = self.fields.clone();
121
+ let _ = &self.score;
122
+ fields.insert("score".to_string(), Value::Null);
123
+ let mut plan: FieldServicePlan = serde_json::from_value(Value::Object(fields))?;
124
+ plan.normalize();
125
+ Ok(plan)
126
+ }
127
+ }
128
+
129
+ impl TelemetryDto {
130
+ pub fn from_runtime(telemetry: &SolverTelemetry) -> Self {
131
+ Self {
132
+ elapsed_ms: duration_to_millis(telemetry.elapsed),
133
+ step_count: telemetry.step_count,
134
+ moves_generated: telemetry.moves_generated,
135
+ moves_evaluated: telemetry.moves_evaluated,
136
+ moves_accepted: telemetry.moves_accepted,
137
+ score_calculations: telemetry.score_calculations,
138
+ generation_ms: duration_to_millis(telemetry.generation_time),
139
+ evaluation_ms: duration_to_millis(telemetry.evaluation_time),
140
+ moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed),
141
+ acceptance_rate: derive_acceptance_rate(
142
+ telemetry.moves_accepted,
143
+ telemetry.moves_evaluated,
144
+ ),
145
+ }
146
+ }
147
+ }
148
+
149
+ impl JobSummaryDto {
150
+ pub fn from_status(job_id: usize, status: &SolverStatus<HardSoftScore>) -> Self {
151
+ Self {
152
+ id: job_id.to_string(),
153
+ job_id: job_id.to_string(),
154
+ lifecycle_state: lifecycle_state_label(status.lifecycle_state),
155
+ terminal_reason: status.terminal_reason.map(terminal_reason_label),
156
+ checkpoint_available: status.checkpoint_available,
157
+ event_sequence: status.event_sequence,
158
+ snapshot_revision: status.latest_snapshot_revision,
159
+ current_score: status.current_score.map(|score| score.to_string()),
160
+ best_score: status.best_score.map(|score| score.to_string()),
161
+ telemetry: TelemetryDto::from_runtime(&status.telemetry),
162
+ }
163
+ }
164
+ }
165
+
166
+ impl JobSnapshotDto {
167
+ pub fn from_snapshot(snapshot: &SolverSnapshot<FieldServicePlan>) -> Self {
168
+ Self {
169
+ id: snapshot.job_id.to_string(),
170
+ job_id: snapshot.job_id.to_string(),
171
+ snapshot_revision: snapshot.snapshot_revision,
172
+ lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
173
+ terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
174
+ current_score: snapshot.current_score.map(|score| score.to_string()),
175
+ best_score: snapshot.best_score.map(|score| score.to_string()),
176
+ telemetry: TelemetryDto::from_runtime(&snapshot.telemetry),
177
+ solution: PlanDto::from_plan(&snapshot.solution),
178
+ }
179
+ }
180
+ }
181
+
182
+ impl JobAnalysisDto {
183
+ pub fn from_snapshot_analysis(
184
+ snapshot: &SolverSnapshotAnalysis<HardSoftScore>,
185
+ analysis: AnalyzeResponse,
186
+ ) -> Self {
187
+ Self {
188
+ id: snapshot.job_id.to_string(),
189
+ job_id: snapshot.job_id.to_string(),
190
+ snapshot_revision: snapshot.snapshot_revision,
191
+ lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
192
+ terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
193
+ analysis,
194
+ }
195
+ }
196
+ }
197
+
198
+ pub fn analysis_response(analysis: &solverforge::ScoreAnalysis<HardSoftScore>) -> AnalyzeResponse {
199
+ AnalyzeResponse {
200
+ score: analysis.score.to_string(),
201
+ constraints: analysis
202
+ .constraints
203
+ .iter()
204
+ .map(|constraint| ConstraintAnalysisDto {
205
+ name: constraint.name.clone(),
206
+ weight: constraint.weight.to_string(),
207
+ score: constraint.score.to_string(),
208
+ match_count: constraint.match_count,
209
+ })
210
+ .collect(),
211
+ }
212
+ }
213
+
214
+ pub fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str {
215
+ match state {
216
+ SolverLifecycleState::Solving => "SOLVING",
217
+ SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED",
218
+ SolverLifecycleState::Paused => "PAUSED",
219
+ SolverLifecycleState::Completed => "COMPLETED",
220
+ SolverLifecycleState::Cancelled => "CANCELLED",
221
+ SolverLifecycleState::Failed => "FAILED",
222
+ }
223
+ }
224
+
225
+ pub fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str {
226
+ match reason {
227
+ SolverTerminalReason::Completed => "completed",
228
+ SolverTerminalReason::TerminatedByConfig => "terminated_by_config",
229
+ SolverTerminalReason::Cancelled => "cancelled",
230
+ SolverTerminalReason::Failed => "failed",
231
+ }
232
+ }
233
+
234
+ fn duration_to_millis(duration: Duration) -> u64 {
235
+ duration.as_millis().min(u128::from(u64::MAX)) as u64
236
+ }
237
+
238
+ fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 {
239
+ let nanos = elapsed.as_nanos();
240
+ if nanos == 0 {
241
+ 0
242
+ } else {
243
+ let per_second = u128::from(count)
244
+ .saturating_mul(1_000_000_000)
245
+ .checked_div(nanos)
246
+ .unwrap_or(0);
247
+ per_second.min(u128::from(u64::MAX)) as u64
248
+ }
249
+ }
250
+
251
+ fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 {
252
+ if moves_evaluated == 0 {
253
+ 0.0
254
+ } else {
255
+ moves_accepted as f64 / moves_evaluated as f64
256
+ }
257
+ }
258
+
259
+ #[cfg(test)]
260
+ mod tests {
261
+ use super::*;
262
+ use crate::domain::{
263
+ FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
264
+ TechnicianRouteInit, TravelLeg,
265
+ };
266
+
267
+ #[test]
268
+ fn to_domain_rebuilds_skipped_indexes_after_json_round_trip() {
269
+ let dto = PlanDto::from_plan(&sample_plan());
270
+ let plan = dto.to_domain().expect("plan should decode");
271
+
272
+ assert_eq!(plan.service_visits[0].index, 0);
273
+ assert_eq!(plan.service_visits[1].index, 1);
274
+ }
275
+
276
+ #[test]
277
+ fn to_domain_refreshes_route_shadows_after_json_round_trip() {
278
+ let dto = PlanDto::from_plan(&sample_plan());
279
+ let plan = dto.to_domain().expect("plan should decode");
280
+
281
+ assert_eq!(plan.technician_routes[0].route_valid_visits, 2);
282
+ }
283
+
284
+ fn sample_plan() -> FieldServicePlan {
285
+ let service_visits = (0..2)
286
+ .map(|idx| {
287
+ ServiceVisit::new(ServiceVisitInit {
288
+ id: format!("visit-{idx}"),
289
+ name: format!("Visit {idx}"),
290
+ customer: format!("Customer {idx}"),
291
+ location_idx: 0,
292
+ duration_minutes: 30,
293
+ earliest_minute: 480,
294
+ latest_minute: 1020,
295
+ required_skill_mask: 0,
296
+ required_parts_mask: 0,
297
+ priority: 1,
298
+ territory: "center".to_string(),
299
+ })
300
+ })
301
+ .collect();
302
+ let mut route = TechnicianRoute::new(TechnicianRouteInit {
303
+ id: "route-0".to_string(),
304
+ technician_id: "tech-0".to_string(),
305
+ technician_name: "Tech 0".to_string(),
306
+ color: "#2563eb".to_string(),
307
+ start_location_idx: 0,
308
+ end_location_idx: 0,
309
+ shift_start_minute: 480,
310
+ shift_end_minute: 1020,
311
+ max_route_minutes: 480,
312
+ skill_mask: 0,
313
+ inventory_mask: 0,
314
+ territory: "center".to_string(),
315
+ });
316
+ route.visits = vec![0, 1];
317
+
318
+ FieldServicePlan::new(
319
+ vec![Location::new(
320
+ "loc-0",
321
+ "Hub",
322
+ "Hub".to_string(),
323
+ 45_700_000,
324
+ 9_670_000,
325
+ "depot".to_string(),
326
+ )],
327
+ service_visits,
328
+ Vec::<TravelLeg>::new(),
329
+ vec![route],
330
+ )
331
+ }
332
+ }
src/api/mod.rs ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! HTTP transport surface for the field-service routing app.
2
+ //!
3
+ //! Routes decode browser requests, DTOs define the JSON contract, route
4
+ //! geometry adapts road-network output, and `SolverService` owns retained jobs.
5
+
6
+ mod dto;
7
+ mod route_dto;
8
+ mod route_geometry;
9
+ mod routes;
10
+ mod sse;
11
+
12
+ pub use dto::PlanDto;
13
+ pub use routes::{router, AppState};
src/api/route_dto.rs ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::Serialize;
2
+
3
+ #[derive(Debug, Clone, Serialize)]
4
+ #[serde(rename_all = "camelCase")]
5
+ pub struct JobRoutesDto {
6
+ pub id: String,
7
+ pub job_id: String,
8
+ pub snapshot_revision: u64,
9
+ pub routes: Vec<TechnicianRouteGeometryDto>,
10
+ }
11
+
12
+ #[derive(Debug, Clone, Serialize)]
13
+ #[serde(rename_all = "camelCase")]
14
+ pub struct TechnicianRouteGeometryDto {
15
+ pub route_id: String,
16
+ pub technician_id: String,
17
+ pub technician_name: String,
18
+ pub color: String,
19
+ pub segments: Vec<RouteSegmentDto>,
20
+ }
21
+
22
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
24
+ pub enum RouteGeometryStatus {
25
+ Routed,
26
+ UnreachableLeg,
27
+ SnapFailed,
28
+ NoPath,
29
+ }
30
+
31
+ #[derive(Debug, Clone, Serialize)]
32
+ #[serde(rename_all = "camelCase")]
33
+ pub struct RouteSegmentDto {
34
+ pub route_id: String,
35
+ pub from_location_idx: usize,
36
+ pub to_location_idx: usize,
37
+ pub duration_seconds: i64,
38
+ pub distance_meters: i64,
39
+ pub reachable: bool,
40
+ pub geometry_status: RouteGeometryStatus,
41
+ pub encoded_polyline: String,
42
+ }
43
+
44
+ impl JobRoutesDto {
45
+ pub fn new(
46
+ job_id: usize,
47
+ snapshot_revision: u64,
48
+ routes: Vec<TechnicianRouteGeometryDto>,
49
+ ) -> Self {
50
+ Self {
51
+ id: job_id.to_string(),
52
+ job_id: job_id.to_string(),
53
+ snapshot_revision,
54
+ routes,
55
+ }
56
+ }
57
+ }
src/api/route_geometry.rs ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Browser route-geometry builder for FSR snapshots.
2
+ //!
3
+ //! Scoring uses cached `TravelLeg` facts so local search remains cheap. The
4
+ //! browser asks for drawable road geometry only for retained snapshots, and this
5
+ //! module converts those route legs into per-segment DTOs.
6
+
7
+ use axum::http::StatusCode;
8
+
9
+ use super::route_dto::{RouteGeometryStatus, RouteSegmentDto, TechnicianRouteGeometryDto};
10
+ use crate::data::{load_network, DemoDataError};
11
+ use crate::domain::{FieldServicePlan, TravelLeg};
12
+
13
+ pub(super) fn status_from_routing_error(error: solverforge_maps::RoutingError) -> StatusCode {
14
+ eprintln!("Bergamo route geometry failed: {error}");
15
+ match error {
16
+ solverforge_maps::RoutingError::InvalidCoordinate { .. } => StatusCode::BAD_REQUEST,
17
+ solverforge_maps::RoutingError::Cancelled => StatusCode::REQUEST_TIMEOUT,
18
+ solverforge_maps::RoutingError::Network(_)
19
+ | solverforge_maps::RoutingError::Parse(_)
20
+ | solverforge_maps::RoutingError::Io(_)
21
+ | solverforge_maps::RoutingError::SnapFailed { .. }
22
+ | solverforge_maps::RoutingError::NoPath { .. } => StatusCode::BAD_GATEWAY,
23
+ }
24
+ }
25
+
26
+ pub(super) async fn build_route_geometry(
27
+ plan: &FieldServicePlan,
28
+ ) -> Result<Vec<TechnicianRouteGeometryDto>, solverforge_maps::RoutingError> {
29
+ // Geometry is built on demand for retained snapshots. It is separate from
30
+ // scoring so local search can use cached matrix facts without asking the map
31
+ // service to draw every candidate move.
32
+ let network = load_network().await.map_err(|error| match error {
33
+ DemoDataError::Routing(error) => error,
34
+ })?;
35
+ let mut routes = Vec::with_capacity(plan.technician_routes.len());
36
+
37
+ for route in &plan.technician_routes {
38
+ let mut segments = Vec::new();
39
+ let mut previous_location_idx = route.start_location_idx;
40
+ for &visit_idx in &route.visits {
41
+ let Some(visit) = plan.service_visits.get(visit_idx) else {
42
+ continue;
43
+ };
44
+ segments.push(build_route_segment(
45
+ plan,
46
+ &network,
47
+ &route.id,
48
+ previous_location_idx,
49
+ visit.location_idx,
50
+ )?);
51
+ previous_location_idx = visit.location_idx;
52
+ }
53
+ if !route.visits.is_empty() {
54
+ segments.push(build_route_segment(
55
+ plan,
56
+ &network,
57
+ &route.id,
58
+ previous_location_idx,
59
+ route.end_location_idx,
60
+ )?);
61
+ }
62
+
63
+ routes.push(TechnicianRouteGeometryDto {
64
+ route_id: route.id.clone(),
65
+ technician_id: route.technician_id.clone(),
66
+ technician_name: route.technician_name.clone(),
67
+ color: route.color.clone(),
68
+ segments,
69
+ });
70
+ }
71
+
72
+ Ok(routes)
73
+ }
74
+
75
+ fn build_route_segment(
76
+ plan: &FieldServicePlan,
77
+ network: &solverforge_maps::RoadNetwork,
78
+ route_id: &str,
79
+ from_location_idx: usize,
80
+ to_location_idx: usize,
81
+ ) -> Result<RouteSegmentDto, solverforge_maps::RoutingError> {
82
+ let travel_leg = find_travel_leg(plan, from_location_idx, to_location_idx);
83
+ if !travel_leg.is_some_and(|leg| leg.reachable) {
84
+ // Preserve the segment in the response even when it cannot be drawn.
85
+ // The UI can then show a partial route instead of hiding useful legs.
86
+ return Ok(non_routed_segment(
87
+ route_id,
88
+ from_location_idx,
89
+ to_location_idx,
90
+ travel_leg,
91
+ RouteGeometryStatus::UnreachableLeg,
92
+ ));
93
+ }
94
+
95
+ let from = plan.locations.get(from_location_idx).ok_or_else(|| {
96
+ solverforge_maps::RoutingError::Network("route source location missing".into())
97
+ })?;
98
+ let to = plan.locations.get(to_location_idx).ok_or_else(|| {
99
+ solverforge_maps::RoutingError::Network("route target location missing".into())
100
+ })?;
101
+
102
+ let route_result = network.route(
103
+ solverforge_maps::Coord::new(from.lat(), from.lng()),
104
+ solverforge_maps::Coord::new(to.lat(), to.lng()),
105
+ );
106
+ let route = match route_result {
107
+ Ok(route) => route.simplify(12.0),
108
+ Err(error) => {
109
+ // Snap and no-path failures are segment-level map problems. Treat
110
+ // them as display status rather than failing the whole snapshot.
111
+ if let Some(status) = recoverable_geometry_status(&error) {
112
+ return Ok(non_routed_segment(
113
+ route_id,
114
+ from_location_idx,
115
+ to_location_idx,
116
+ travel_leg,
117
+ status,
118
+ ));
119
+ }
120
+ return Err(error);
121
+ }
122
+ };
123
+
124
+ Ok(RouteSegmentDto {
125
+ route_id: route_id.to_string(),
126
+ from_location_idx,
127
+ to_location_idx,
128
+ duration_seconds: route.duration_seconds,
129
+ distance_meters: route.distance_meters.round() as i64,
130
+ reachable: true,
131
+ geometry_status: RouteGeometryStatus::Routed,
132
+ encoded_polyline: solverforge_maps::encode_polyline(&route.geometry),
133
+ })
134
+ }
135
+
136
+ fn find_travel_leg(
137
+ plan: &FieldServicePlan,
138
+ from_location_idx: usize,
139
+ to_location_idx: usize,
140
+ ) -> Option<&TravelLeg> {
141
+ let width = plan.locations.len();
142
+ plan.travel_legs
143
+ .get(from_location_idx.checked_mul(width)? + to_location_idx)
144
+ .filter(|leg| {
145
+ leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx
146
+ })
147
+ .or_else(|| {
148
+ plan.travel_legs.iter().find(|leg| {
149
+ leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx
150
+ })
151
+ })
152
+ }
153
+
154
+ fn non_routed_segment(
155
+ route_id: &str,
156
+ from_location_idx: usize,
157
+ to_location_idx: usize,
158
+ travel_leg: Option<&TravelLeg>,
159
+ geometry_status: RouteGeometryStatus,
160
+ ) -> RouteSegmentDto {
161
+ RouteSegmentDto {
162
+ route_id: route_id.to_string(),
163
+ from_location_idx,
164
+ to_location_idx,
165
+ duration_seconds: travel_leg.map_or(0, |leg| leg.duration_seconds),
166
+ distance_meters: travel_leg.map_or(0, |leg| leg.distance_meters),
167
+ reachable: false,
168
+ geometry_status,
169
+ encoded_polyline: String::new(),
170
+ }
171
+ }
172
+
173
+ fn recoverable_geometry_status(
174
+ error: &solverforge_maps::RoutingError,
175
+ ) -> Option<RouteGeometryStatus> {
176
+ match error {
177
+ solverforge_maps::RoutingError::SnapFailed { .. } => Some(RouteGeometryStatus::SnapFailed),
178
+ solverforge_maps::RoutingError::NoPath { .. } => Some(RouteGeometryStatus::NoPath),
179
+ _ => None,
180
+ }
181
+ }
182
+
183
+ #[cfg(test)]
184
+ mod tests {
185
+ use super::*;
186
+ use crate::domain::{FieldServicePlan, Location, TravelLegInit};
187
+
188
+ #[test]
189
+ fn finds_dense_or_sparse_travel_leg() {
190
+ let plan = test_plan(vec![TravelLeg::new(TravelLegInit {
191
+ id: "leg-01-02".to_string(),
192
+ name: "leg-01-02".to_string(),
193
+ from_location_idx: 1,
194
+ to_location_idx: 2,
195
+ duration_seconds: 42,
196
+ distance_meters: 1000,
197
+ reachable: true,
198
+ })]);
199
+
200
+ let leg = find_travel_leg(&plan, 1, 2).expect("travel leg");
201
+
202
+ assert_eq!(leg.duration_seconds, 42);
203
+ }
204
+
205
+ #[test]
206
+ fn non_routed_segment_preserves_known_metrics() {
207
+ let plan = test_plan(vec![TravelLeg::new(TravelLegInit {
208
+ id: "leg-00-01".to_string(),
209
+ name: "leg-00-01".to_string(),
210
+ from_location_idx: 0,
211
+ to_location_idx: 1,
212
+ duration_seconds: 90,
213
+ distance_meters: 1200,
214
+ reachable: false,
215
+ })]);
216
+
217
+ let segment = non_routed_segment(
218
+ "route-00",
219
+ 0,
220
+ 1,
221
+ find_travel_leg(&plan, 0, 1),
222
+ RouteGeometryStatus::UnreachableLeg,
223
+ );
224
+
225
+ assert!(!segment.reachable);
226
+ assert_eq!(segment.geometry_status, RouteGeometryStatus::UnreachableLeg);
227
+ assert_eq!(segment.duration_seconds, 90);
228
+ assert!(segment.encoded_polyline.is_empty());
229
+ }
230
+
231
+ #[test]
232
+ fn only_snap_and_no_path_are_recoverable_segment_failures() {
233
+ let from = solverforge_maps::Coord::new(45.0, 9.0);
234
+ let to = solverforge_maps::Coord::new(46.0, 10.0);
235
+
236
+ assert_eq!(
237
+ recoverable_geometry_status(&solverforge_maps::RoutingError::NoPath { from, to }),
238
+ Some(RouteGeometryStatus::NoPath)
239
+ );
240
+ assert_eq!(
241
+ recoverable_geometry_status(&solverforge_maps::RoutingError::SnapFailed {
242
+ coord: from,
243
+ nearest_distance_m: None,
244
+ }),
245
+ Some(RouteGeometryStatus::SnapFailed)
246
+ );
247
+ assert_eq!(
248
+ recoverable_geometry_status(&solverforge_maps::RoutingError::Network("down".into())),
249
+ None
250
+ );
251
+ }
252
+
253
+ fn test_plan(travel_legs: Vec<TravelLeg>) -> FieldServicePlan {
254
+ FieldServicePlan::new(
255
+ vec![
256
+ Location::new(
257
+ "loc-0",
258
+ "loc-0",
259
+ "A".into(),
260
+ 45_000_000,
261
+ 9_000_000,
262
+ "x".into(),
263
+ ),
264
+ Location::new(
265
+ "loc-1",
266
+ "loc-1",
267
+ "B".into(),
268
+ 45_001_000,
269
+ 9_001_000,
270
+ "x".into(),
271
+ ),
272
+ Location::new(
273
+ "loc-2",
274
+ "loc-2",
275
+ "C".into(),
276
+ 45_002_000,
277
+ 9_002_000,
278
+ "x".into(),
279
+ ),
280
+ ],
281
+ Vec::new(),
282
+ travel_legs,
283
+ Vec::new(),
284
+ )
285
+ }
286
+ }
src/api/routes.rs ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! HTTP routes for the field-service routing app.
2
+ //!
3
+ //! Handlers intentionally stay narrow: parse the route/query, ask the data or
4
+ //! retained solver service for the domain value, then return a DTO.
5
+
6
+ use axum::{
7
+ extract::{Path, Query, State},
8
+ http::StatusCode,
9
+ routing::{get, post},
10
+ Json, Router,
11
+ };
12
+ use serde::{Deserialize, Serialize};
13
+ use std::sync::Arc;
14
+
15
+ use super::dto::{analysis_response, JobAnalysisDto, JobSnapshotDto, JobSummaryDto, PlanDto};
16
+ use super::route_dto::JobRoutesDto;
17
+ use super::route_geometry::{build_route_geometry, status_from_routing_error};
18
+ use super::sse;
19
+ use crate::data::{generate, prepare_routing, DemoData, DemoDataError};
20
+ use crate::solver::SolverService;
21
+
22
+ /// Shared application state.
23
+ pub struct AppState {
24
+ pub solver: SolverService,
25
+ }
26
+
27
+ impl AppState {
28
+ pub fn new() -> Self {
29
+ Self {
30
+ solver: SolverService::new(),
31
+ }
32
+ }
33
+ }
34
+
35
+ impl Default for AppState {
36
+ fn default() -> Self {
37
+ Self::new()
38
+ }
39
+ }
40
+
41
+ /// Creates the API router.
42
+ pub fn router(state: Arc<AppState>) -> Router {
43
+ Router::new()
44
+ .route("/health", get(health))
45
+ .route("/info", get(info))
46
+ .route("/demo-data", get(list_demo_data))
47
+ .route("/demo-data/{id}", get(get_demo_data))
48
+ .route("/jobs", post(create_job))
49
+ .route("/jobs/{id}", get(get_job).delete(delete_job))
50
+ .route("/jobs/{id}/status", get(get_job_status))
51
+ .route("/jobs/{id}/snapshot", get(get_snapshot))
52
+ .route("/jobs/{id}/analysis", get(analyze_by_id))
53
+ .route("/jobs/{id}/routes", get(get_routes))
54
+ .route("/jobs/{id}/pause", post(pause_job))
55
+ .route("/jobs/{id}/resume", post(resume_job))
56
+ .route("/jobs/{id}/cancel", post(cancel_job))
57
+ .route("/jobs/{id}/events", get(sse::events))
58
+ .with_state(state)
59
+ }
60
+
61
+ #[derive(Serialize)]
62
+ struct HealthResponse {
63
+ status: &'static str,
64
+ }
65
+
66
+ async fn health() -> Json<HealthResponse> {
67
+ Json(HealthResponse { status: "UP" })
68
+ }
69
+
70
+ #[derive(Serialize)]
71
+ #[serde(rename_all = "camelCase")]
72
+ struct InfoResponse {
73
+ name: &'static str,
74
+ version: &'static str,
75
+ solver_engine: &'static str,
76
+ }
77
+
78
+ async fn info() -> Json<InfoResponse> {
79
+ Json(InfoResponse {
80
+ name: env!("CARGO_PKG_NAME"),
81
+ version: env!("CARGO_PKG_VERSION"),
82
+ solver_engine: "SolverForge",
83
+ })
84
+ }
85
+
86
+ #[derive(Serialize)]
87
+ #[serde(rename_all = "camelCase")]
88
+ struct DemoDataCatalogResponse {
89
+ default_id: &'static str,
90
+ available_ids: Vec<&'static str>,
91
+ }
92
+
93
+ async fn list_demo_data() -> Json<DemoDataCatalogResponse> {
94
+ Json(DemoDataCatalogResponse {
95
+ default_id: DemoData::default_demo_data().id(),
96
+ available_ids: DemoData::available_demo_data()
97
+ .iter()
98
+ .map(|demo| demo.id())
99
+ .collect(),
100
+ })
101
+ }
102
+
103
+ async fn get_demo_data(Path(id): Path<String>) -> Result<Json<PlanDto>, StatusCode> {
104
+ let demo = id.parse::<DemoData>().map_err(|_| StatusCode::NOT_FOUND)?;
105
+ let plan = generate(demo).await.map_err(status_from_demo_data_error)?;
106
+ Ok(Json(PlanDto::from_plan(&plan)))
107
+ }
108
+
109
+ #[derive(Serialize)]
110
+ #[serde(rename_all = "camelCase")]
111
+ struct CreateJobResponse {
112
+ id: String,
113
+ }
114
+
115
+ async fn create_job(
116
+ State(state): State<Arc<AppState>>,
117
+ Json(dto): Json<PlanDto>,
118
+ ) -> Result<Json<CreateJobResponse>, StatusCode> {
119
+ let mut plan = dto.to_domain().map_err(|_| StatusCode::BAD_REQUEST)?;
120
+ prepare_routing(&mut plan)
121
+ .await
122
+ .map_err(status_from_demo_data_error)?;
123
+ let id = state
124
+ .solver
125
+ .start_job(plan)
126
+ .map_err(status_from_solver_error)?;
127
+ Ok(Json(CreateJobResponse { id }))
128
+ }
129
+
130
+ async fn get_job(
131
+ State(state): State<Arc<AppState>>,
132
+ Path(id): Path<String>,
133
+ ) -> Result<Json<JobSummaryDto>, StatusCode> {
134
+ let job_id = parse_job_id(&id)?;
135
+ let status = state
136
+ .solver
137
+ .get_status(&id)
138
+ .map_err(status_from_solver_error)?;
139
+ Ok(Json(JobSummaryDto::from_status(job_id, &status)))
140
+ }
141
+
142
+ async fn get_job_status(
143
+ State(state): State<Arc<AppState>>,
144
+ Path(id): Path<String>,
145
+ ) -> Result<Json<JobSummaryDto>, StatusCode> {
146
+ get_job(State(state), Path(id)).await
147
+ }
148
+
149
+ #[derive(Debug, Default, Deserialize)]
150
+ struct SnapshotQuery {
151
+ snapshot_revision: Option<u64>,
152
+ }
153
+
154
+ async fn get_snapshot(
155
+ State(state): State<Arc<AppState>>,
156
+ Path(id): Path<String>,
157
+ Query(query): Query<SnapshotQuery>,
158
+ ) -> Result<Json<JobSnapshotDto>, StatusCode> {
159
+ let snapshot = state
160
+ .solver
161
+ .get_snapshot(&id, query.snapshot_revision)
162
+ .map_err(status_from_solver_error)?;
163
+ Ok(Json(JobSnapshotDto::from_snapshot(&snapshot)))
164
+ }
165
+
166
+ async fn analyze_by_id(
167
+ State(state): State<Arc<AppState>>,
168
+ Path(id): Path<String>,
169
+ Query(query): Query<SnapshotQuery>,
170
+ ) -> Result<Json<JobAnalysisDto>, StatusCode> {
171
+ let snapshot_analysis = state
172
+ .solver
173
+ .analyze_snapshot(&id, query.snapshot_revision)
174
+ .map_err(status_from_solver_error)?;
175
+ let analysis = analysis_response(&snapshot_analysis.analysis);
176
+ Ok(Json(JobAnalysisDto::from_snapshot_analysis(
177
+ &snapshot_analysis,
178
+ analysis,
179
+ )))
180
+ }
181
+
182
+ async fn get_routes(
183
+ State(state): State<Arc<AppState>>,
184
+ Path(id): Path<String>,
185
+ Query(query): Query<SnapshotQuery>,
186
+ ) -> Result<Json<JobRoutesDto>, StatusCode> {
187
+ let job_id = parse_job_id(&id)?;
188
+ let snapshot = state
189
+ .solver
190
+ .get_snapshot(&id, query.snapshot_revision)
191
+ .map_err(status_from_solver_error)?;
192
+ let routes = build_route_geometry(&snapshot.solution)
193
+ .await
194
+ .map_err(status_from_routing_error)?;
195
+ Ok(Json(JobRoutesDto::new(
196
+ job_id,
197
+ snapshot.snapshot_revision,
198
+ routes,
199
+ )))
200
+ }
201
+
202
+ async fn pause_job(
203
+ State(state): State<Arc<AppState>>,
204
+ Path(id): Path<String>,
205
+ ) -> Result<StatusCode, StatusCode> {
206
+ state.solver.pause(&id).map_err(status_from_solver_error)?;
207
+ Ok(StatusCode::ACCEPTED)
208
+ }
209
+
210
+ async fn resume_job(
211
+ State(state): State<Arc<AppState>>,
212
+ Path(id): Path<String>,
213
+ ) -> Result<StatusCode, StatusCode> {
214
+ state.solver.resume(&id).map_err(status_from_solver_error)?;
215
+ Ok(StatusCode::ACCEPTED)
216
+ }
217
+
218
+ async fn cancel_job(
219
+ State(state): State<Arc<AppState>>,
220
+ Path(id): Path<String>,
221
+ ) -> Result<StatusCode, StatusCode> {
222
+ state.solver.cancel(&id).map_err(status_from_solver_error)?;
223
+ Ok(StatusCode::ACCEPTED)
224
+ }
225
+
226
+ async fn delete_job(
227
+ State(state): State<Arc<AppState>>,
228
+ Path(id): Path<String>,
229
+ ) -> Result<StatusCode, StatusCode> {
230
+ state.solver.delete(&id).map_err(status_from_solver_error)?;
231
+ Ok(StatusCode::NO_CONTENT)
232
+ }
233
+
234
+ fn parse_job_id(id: &str) -> Result<usize, StatusCode> {
235
+ id.parse::<usize>().map_err(|_| StatusCode::NOT_FOUND)
236
+ }
237
+
238
+ fn status_from_solver_error(error: solverforge::SolverManagerError) -> StatusCode {
239
+ match error {
240
+ solverforge::SolverManagerError::NoFreeJobSlots => StatusCode::SERVICE_UNAVAILABLE,
241
+ solverforge::SolverManagerError::JobNotFound { .. } => StatusCode::NOT_FOUND,
242
+ solverforge::SolverManagerError::InvalidStateTransition { .. } => StatusCode::CONFLICT,
243
+ solverforge::SolverManagerError::NoSnapshotAvailable { .. } => StatusCode::CONFLICT,
244
+ solverforge::SolverManagerError::SnapshotNotFound { .. } => StatusCode::NOT_FOUND,
245
+ }
246
+ }
247
+
248
+ fn status_from_demo_data_error(error: DemoDataError) -> StatusCode {
249
+ eprintln!("{error}");
250
+ StatusCode::SERVICE_UNAVAILABLE
251
+ }
src/api/sse.rs ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Server-sent events for retained FSR solve jobs.
2
+ //!
3
+ //! The first frame is a bootstrap status or snapshot so late subscribers can
4
+ //! render immediately. Later frames come from the job's broadcast channel.
5
+
6
+ use axum::{
7
+ body::Body,
8
+ extract::{Path, State},
9
+ http::{header, StatusCode},
10
+ response::Response,
11
+ };
12
+ use std::sync::Arc;
13
+ use tokio_stream::wrappers::BroadcastStream;
14
+ use tokio_stream::StreamExt;
15
+
16
+ use super::routes::AppState;
17
+
18
+ pub async fn events(
19
+ State(state): State<Arc<AppState>>,
20
+ Path(id): Path<String>,
21
+ ) -> Result<Response<Body>, StatusCode> {
22
+ let rx = state.solver.subscribe(&id).ok_or(StatusCode::NOT_FOUND)?;
23
+ let bootstrap_json = state
24
+ .solver
25
+ .bootstrap_event(&id)
26
+ .map_err(|_| StatusCode::NOT_FOUND)?;
27
+ let bootstrap_event_sequence = event_sequence_from_json(&bootstrap_json);
28
+ let bootstrap = tokio_stream::iter(std::iter::once(Ok::<_, std::convert::Infallible>(
29
+ format!("data: {}\n\n", bootstrap_json).into_bytes(),
30
+ )));
31
+
32
+ let live = BroadcastStream::new(rx).filter_map(move |msg| match msg {
33
+ Ok(json) => {
34
+ if event_is_not_newer(&json, bootstrap_event_sequence) {
35
+ return None;
36
+ }
37
+ Some(Ok::<_, std::convert::Infallible>(
38
+ format!("data: {}\n\n", json).into_bytes(),
39
+ ))
40
+ }
41
+ Err(_) => None, // Lagged - skip missed messages
42
+ });
43
+
44
+ let stream = bootstrap.chain(live);
45
+
46
+ Ok(Response::builder()
47
+ .header(header::CONTENT_TYPE, "text/event-stream")
48
+ .header(header::CACHE_CONTROL, "no-cache")
49
+ .header("X-Accel-Buffering", "no")
50
+ .body(Body::from_stream(stream))
51
+ .unwrap())
52
+ }
53
+
54
+ fn event_sequence_from_json(json: &str) -> Option<u64> {
55
+ serde_json::from_str::<serde_json::Value>(json)
56
+ .ok()
57
+ .and_then(|value| {
58
+ value
59
+ .get("eventSequence")
60
+ .and_then(serde_json::Value::as_u64)
61
+ })
62
+ }
63
+
64
+ /// Returns true when a live event is already covered by the bootstrap frame.
65
+ fn event_is_not_newer(json: &str, bootstrap_event_sequence: Option<u64>) -> bool {
66
+ let Some(bootstrap_event_sequence) = bootstrap_event_sequence else {
67
+ return false;
68
+ };
69
+ event_sequence_from_json(json)
70
+ .is_some_and(|event_sequence| event_sequence <= bootstrap_event_sequence)
71
+ }
src/constraints/assigned_visits.rs ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Assignment-coverage rules for field-service visits.
2
+ //!
3
+ //! Missing and invalid assignments fit stock SolverForge streams. Duplicate
4
+ //! assignments need an exact count and an accurate analysis match count, so that
5
+ //! rule uses a small custom incremental constraint instead of a grouped stream
6
+ //! that would count singleton groups as matches.
7
+
8
+ use crate::domain::{
9
+ FieldServicePlan, FieldServicePlanConstraintStreams, ServiceVisit, TechnicianRoute,
10
+ };
11
+ use solverforge::prelude::*;
12
+ use solverforge::stream::joiner::equal_bi;
13
+ use solverforge::{ConstraintSet, IncrementalConstraint, IncrementalConstraintSealed};
14
+ use solverforge_core::ConstraintRef;
15
+
16
+ pub(super) fn constraint() -> impl ConstraintSet<FieldServicePlan, HardSoftScore> {
17
+ (
18
+ missing_visits(),
19
+ duplicate_assignments(),
20
+ invalid_assignments(),
21
+ )
22
+ }
23
+
24
+ pub(super) fn missing_visits() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
25
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
26
+ .service_visits()
27
+ .if_not_exists((
28
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
29
+ .technician_routes()
30
+ .flattened(|route: &TechnicianRoute| &route.visits),
31
+ equal_bi(
32
+ |visit: &ServiceVisit| visit.index,
33
+ |assigned_visit_idx: &usize| *assigned_visit_idx,
34
+ ),
35
+ ))
36
+ .penalize(hard_weight(|_: &ServiceVisit| HardSoftScore::of(1, 0)))
37
+ .named("Assigned Visits")
38
+ }
39
+
40
+ pub(super) fn duplicate_assignments() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore>
41
+ {
42
+ DuplicateAssignmentsConstraint::new()
43
+ }
44
+
45
+ pub(super) fn invalid_assignments() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
46
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
47
+ .technician_routes()
48
+ .filter(|route: &TechnicianRoute| route.route_invalid_visits > 0)
49
+ .penalize(hard_weight(|route: &TechnicianRoute| {
50
+ HardSoftScore::of(route.route_invalid_visits, 0)
51
+ }))
52
+ .named("Invalid Visit Assignments")
53
+ }
54
+
55
+ struct DuplicateAssignmentsConstraint {
56
+ constraint_ref: ConstraintRef,
57
+ last_score: HardSoftScore,
58
+ }
59
+
60
+ #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
61
+ struct DuplicateAssignmentTotals {
62
+ duplicate_groups: usize,
63
+ extra_assignments: i64,
64
+ }
65
+
66
+ impl DuplicateAssignmentsConstraint {
67
+ fn new() -> Self {
68
+ Self {
69
+ constraint_ref: ConstraintRef::new("", "Duplicate Visit Assignments"),
70
+ last_score: HardSoftScore::ZERO,
71
+ }
72
+ }
73
+
74
+ fn score_for(plan: &FieldServicePlan) -> HardSoftScore {
75
+ let totals = duplicate_assignment_totals(plan);
76
+ HardSoftScore::of(-totals.extra_assignments, 0)
77
+ }
78
+ }
79
+
80
+ impl IncrementalConstraintSealed for DuplicateAssignmentsConstraint {}
81
+
82
+ impl IncrementalConstraint<FieldServicePlan, HardSoftScore> for DuplicateAssignmentsConstraint {
83
+ fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore {
84
+ Self::score_for(solution)
85
+ }
86
+
87
+ fn match_count(&self, solution: &FieldServicePlan) -> usize {
88
+ duplicate_assignment_totals(solution).duplicate_groups
89
+ }
90
+
91
+ fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore {
92
+ self.last_score = Self::score_for(solution);
93
+ self.last_score
94
+ }
95
+
96
+ fn on_insert(
97
+ &mut self,
98
+ solution: &FieldServicePlan,
99
+ _entity_index: usize,
100
+ _descriptor_index: usize,
101
+ ) -> HardSoftScore {
102
+ let next_score = Self::score_for(solution);
103
+ let delta = next_score - self.last_score;
104
+ self.last_score = next_score;
105
+ delta
106
+ }
107
+
108
+ fn on_retract(
109
+ &mut self,
110
+ _solution: &FieldServicePlan,
111
+ _entity_index: usize,
112
+ _descriptor_index: usize,
113
+ ) -> HardSoftScore {
114
+ HardSoftScore::ZERO
115
+ }
116
+
117
+ fn reset(&mut self) {
118
+ self.last_score = HardSoftScore::ZERO;
119
+ }
120
+
121
+ fn constraint_ref(&self) -> &ConstraintRef {
122
+ &self.constraint_ref
123
+ }
124
+
125
+ fn is_hard(&self) -> bool {
126
+ true
127
+ }
128
+ }
129
+
130
+ fn duplicate_assignment_totals(plan: &FieldServicePlan) -> DuplicateAssignmentTotals {
131
+ let mut counts = vec![0usize; plan.service_visits.len()];
132
+ for route in &plan.technician_routes {
133
+ for &visit_idx in &route.visits {
134
+ if let Some(count) = counts.get_mut(visit_idx) {
135
+ *count += 1;
136
+ }
137
+ }
138
+ }
139
+
140
+ counts.iter().filter(|&&count| count > 1).fold(
141
+ DuplicateAssignmentTotals::default(),
142
+ |mut totals, &count| {
143
+ totals.duplicate_groups += 1;
144
+ totals.extra_assignments += (count - 1) as i64;
145
+ totals
146
+ },
147
+ )
148
+ }
149
+
150
+ #[cfg(test)]
151
+ mod tests {
152
+ use super::*;
153
+ use crate::domain::{
154
+ FieldServicePlan, ServiceVisit, ServiceVisitInit, TechnicianRoute, TechnicianRouteInit,
155
+ };
156
+ use solverforge::ConstraintSet;
157
+
158
+ #[test]
159
+ fn empty_routes_are_penalized_for_unassigned_visits() {
160
+ let score = assignment_constraints().evaluate_all(&sample_plan(vec![vec![]]));
161
+
162
+ assert_eq!(score, HardSoftScore::of(-2, 0));
163
+ }
164
+
165
+ #[test]
166
+ fn every_visit_once_is_feasible() {
167
+ let score = assignment_constraints().evaluate_all(&sample_plan(vec![vec![0, 1]]));
168
+
169
+ assert_eq!(score, HardSoftScore::ZERO);
170
+ }
171
+
172
+ #[test]
173
+ fn duplicate_assignments_are_penalized_even_when_no_visit_is_missing() {
174
+ let score = assignment_constraints().evaluate_all(&sample_plan(vec![vec![0, 1, 1]]));
175
+
176
+ assert_eq!(score, HardSoftScore::of(-1, 0));
177
+ }
178
+
179
+ #[test]
180
+ fn duplicate_assignment_analysis_counts_only_duplicate_groups() {
181
+ let feasible_plan = sample_plan(vec![vec![0, 1]]);
182
+ let duplicate_plan = sample_plan(vec![vec![0, 1, 1]]);
183
+ let triplicate_plan = sample_plan(vec![vec![0, 0, 0, 1]]);
184
+ let constraint = duplicate_assignments();
185
+
186
+ assert_eq!(constraint.match_count(&feasible_plan), 0);
187
+ assert_eq!(constraint.match_count(&duplicate_plan), 1);
188
+ assert_eq!(constraint.match_count(&triplicate_plan), 1);
189
+ assert_eq!(
190
+ constraint.evaluate(&triplicate_plan),
191
+ HardSoftScore::of(-2, 0)
192
+ );
193
+ }
194
+
195
+ #[test]
196
+ fn duplicate_or_invalid_visit_indexes_are_hard_issues() {
197
+ let score = assignment_constraints().evaluate_all(&sample_plan(vec![vec![0, 0, 99]]));
198
+
199
+ assert_eq!(score, HardSoftScore::of(-3, 0));
200
+ }
201
+
202
+ #[test]
203
+ fn invalid_visit_indexes_are_not_counted_as_duplicate_service_visits() {
204
+ let score = assignment_constraints().evaluate_all(&sample_plan(vec![vec![0, 1, 99, 99]]));
205
+
206
+ assert_eq!(score, HardSoftScore::of(-2, 0));
207
+ }
208
+
209
+ #[test]
210
+ fn duplicate_assignment_incremental_delta_matches_fresh_score() {
211
+ let mut plan = sample_plan(vec![vec![0, 1]]);
212
+ let mut constraints = assignment_constraints();
213
+ let initial = constraints.initialize_all(&plan);
214
+
215
+ let retract_delta = constraints.on_retract_all(&plan, 0, 0);
216
+ plan.technician_routes[0].visits.push(1);
217
+ plan.refresh_technician_route_shadows(0);
218
+ let insert_delta = constraints.on_insert_all(&plan, 0, 0);
219
+
220
+ assert_eq!(
221
+ initial + retract_delta + insert_delta,
222
+ constraints.evaluate_all(&plan)
223
+ );
224
+ }
225
+
226
+ fn assignment_constraints() -> impl ConstraintSet<FieldServicePlan, HardSoftScore> {
227
+ (
228
+ missing_visits(),
229
+ duplicate_assignments(),
230
+ invalid_assignments(),
231
+ )
232
+ }
233
+
234
+ fn sample_plan(route_visits: Vec<Vec<usize>>) -> FieldServicePlan {
235
+ let service_visits = (0..2)
236
+ .map(|idx| {
237
+ ServiceVisit::new(ServiceVisitInit {
238
+ id: format!("visit-{idx}"),
239
+ name: format!("Visit {idx}"),
240
+ customer: format!("Customer {idx}"),
241
+ location_idx: idx,
242
+ duration_minutes: 30,
243
+ earliest_minute: 480,
244
+ latest_minute: 1020,
245
+ required_skill_mask: 0,
246
+ required_parts_mask: 0,
247
+ priority: 1,
248
+ territory: "center".to_string(),
249
+ })
250
+ })
251
+ .collect();
252
+ let technician_routes = route_visits
253
+ .into_iter()
254
+ .enumerate()
255
+ .map(|(idx, visits)| {
256
+ let mut route = TechnicianRoute::new(TechnicianRouteInit {
257
+ id: format!("route-{idx}"),
258
+ technician_id: format!("tech-{idx}"),
259
+ technician_name: format!("Tech {idx}"),
260
+ color: "#2563eb".to_string(),
261
+ start_location_idx: 0,
262
+ end_location_idx: 0,
263
+ shift_start_minute: 480,
264
+ shift_end_minute: 1020,
265
+ max_route_minutes: 480,
266
+ skill_mask: 0,
267
+ inventory_mask: 0,
268
+ territory: "center".to_string(),
269
+ });
270
+ route.visits = visits;
271
+ route
272
+ })
273
+ .collect();
274
+
275
+ FieldServicePlan::new(Vec::new(), service_visits, Vec::new(), technician_routes)
276
+ }
277
+ }
src/constraints/balance_workload.rs ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, FieldServicePlanConstraintStreams, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// SOFT: discourage concentrating all service and travel minutes on one route.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
8
+ .technician_routes()
9
+ .filter(|route: &TechnicianRoute| route.workload_penalty() > 0)
10
+ .penalize(|route: &TechnicianRoute| HardSoftScore::of(0, route.workload_penalty()))
11
+ .named("Balance Workload")
12
+ }
src/constraints/minimize_travel.rs ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, FieldServicePlanConstraintStreams, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// SOFT: minimize road travel time and distance across technician routes.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
8
+ .technician_routes()
9
+ .filter(|route: &TechnicianRoute| route.travel_penalty() > 0)
10
+ .penalize(|route: &TechnicianRoute| HardSoftScore::of(0, route.travel_penalty()))
11
+ .named("Minimize Travel")
12
+ }
src/constraints/mod.rs ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Constraint assembly for field-service routing.
2
+ //!
3
+ //! Each child module owns one business rule and uses stock SolverForge
4
+ //! `ConstraintFactory` streams. Route-level calculations are maintained as
5
+ //! domain shadow values so the scoring layer stays declarative.
6
+
7
+ use crate::domain::FieldServicePlan;
8
+ use solverforge::prelude::*;
9
+
10
+ pub use self::assemble::create_constraints;
11
+
12
+ #[cfg(test)]
13
+ mod route_metrics_tests;
14
+
15
+ // @solverforge:begin constraint-modules
16
+ mod assigned_visits;
17
+ mod balance_workload;
18
+ mod minimize_travel;
19
+ mod priority_slack;
20
+ mod reachable_legs;
21
+ mod required_parts;
22
+ mod required_skills;
23
+ mod shift_capacity;
24
+ mod territory_affinity;
25
+ mod time_windows;
26
+ // @solverforge:end constraint-modules
27
+
28
+ mod assemble {
29
+ use super::*;
30
+
31
+ /// Collects the full scoring model used by `FieldServicePlan`.
32
+ pub fn create_constraints() -> impl ConstraintSet<FieldServicePlan, HardSoftScore> {
33
+ // @solverforge:begin constraint-calls
34
+ (
35
+ assigned_visits::constraint(),
36
+ balance_workload::constraint(),
37
+ minimize_travel::constraint(),
38
+ priority_slack::constraint(),
39
+ reachable_legs::constraint(),
40
+ required_parts::constraint(),
41
+ required_skills::constraint(),
42
+ shift_capacity::constraint(),
43
+ territory_affinity::constraint(),
44
+ time_windows::constraint(),
45
+ )
46
+ // @solverforge:end constraint-calls
47
+ }
48
+ }
src/constraints/priority_slack.rs ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, FieldServicePlanConstraintStreams, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// SOFT: reward serving high-priority visits with slack before their deadline.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
8
+ .technician_routes()
9
+ .filter(|route: &TechnicianRoute| route.route_priority_slack > 0)
10
+ .reward(|route: &TechnicianRoute| HardSoftScore::of(0, route.route_priority_slack))
11
+ .named("Priority Slack")
12
+ }
src/constraints/reachable_legs.rs ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, FieldServicePlanConstraintStreams, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// HARD: every depot-to-visit, visit-to-visit, and visit-to-depot leg must be routable.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
8
+ .technician_routes()
9
+ .filter(|route: &TechnicianRoute| route.route_unreachable_legs > 0)
10
+ .penalize(hard_weight(|route: &TechnicianRoute| {
11
+ HardSoftScore::of(route.route_unreachable_legs, 0)
12
+ }))
13
+ .named("Reachable Legs")
14
+ }
src/constraints/required_parts.rs ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, FieldServicePlanConstraintStreams, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// HARD: route inventory must cover every assigned visit's required parts.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
8
+ .technician_routes()
9
+ .filter(|route: &TechnicianRoute| route.route_missing_part_visits > 0)
10
+ .penalize(hard_weight(|route: &TechnicianRoute| {
11
+ HardSoftScore::of(route.route_missing_part_visits, 0)
12
+ }))
13
+ .named("Required Parts")
14
+ }
src/constraints/required_skills.rs ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, FieldServicePlanConstraintStreams, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// HARD: a technician route may only contain visits whose skill mask is covered.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
8
+ .technician_routes()
9
+ .filter(|route: &TechnicianRoute| route.route_missing_skill_visits > 0)
10
+ .penalize(hard_weight(|route: &TechnicianRoute| {
11
+ HardSoftScore::of(route.route_missing_skill_visits, 0)
12
+ }))
13
+ .named("Required Skills")
14
+ }
src/constraints/route_metrics_tests.rs ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{
2
+ route_metrics::{leg_for, route_stats},
3
+ FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
4
+ TechnicianRouteInit, TravelLeg, TravelLegInit,
5
+ };
6
+ use solverforge::ConstraintSet;
7
+
8
+ #[test]
9
+ fn route_stats_accounts_for_travel_service_and_lateness() {
10
+ let plan = sample_plan(vec![0, 1]);
11
+ let stats = route_stats(&plan, &plan.technician_routes[0]);
12
+
13
+ assert_eq!(stats.travel_seconds, 1_800);
14
+ assert_eq!(stats.service_minutes, 75);
15
+ assert_eq!(stats.late_minutes, 0);
16
+ assert_eq!(stats.route_minutes, 125);
17
+ assert_eq!(stats.overtime_minutes, 55);
18
+ assert_eq!(stats.valid_visits, 2);
19
+ assert_eq!(stats.scored_travel_legs, 3);
20
+ assert_eq!(stats.missing_skill_visits, 0);
21
+ assert_eq!(stats.missing_part_visits, 1);
22
+ }
23
+
24
+ #[test]
25
+ fn travel_leg_lookup_prefers_row_major_contract() {
26
+ let plan = sample_plan(vec![0]);
27
+ let leg = leg_for(&plan, 0, 1).expect("leg should exist");
28
+
29
+ assert_eq!(leg.id, "leg-0-1");
30
+ assert!(leg.reachable);
31
+ }
32
+
33
+ #[test]
34
+ fn field_service_plan_deserialize_restores_transient_indexes_and_shadows() {
35
+ let plan = sample_plan(vec![1, 0]);
36
+ let decoded: FieldServicePlan =
37
+ serde_json::from_value(serde_json::to_value(&plan).expect("plan should serialize"))
38
+ .expect("plan should deserialize");
39
+
40
+ let indexes = decoded
41
+ .service_visits
42
+ .iter()
43
+ .map(|visit| visit.index)
44
+ .collect::<Vec<_>>();
45
+ assert_eq!(indexes, vec![0, 1]);
46
+ assert_eq!(decoded.technician_routes[0].route_valid_visits, 2);
47
+ assert!(decoded.technician_routes[0].route_travel_seconds > 0);
48
+ }
49
+
50
+ #[test]
51
+ fn full_constraint_set_reports_expected_hard_penalties() {
52
+ let constraints = crate::constraints::create_constraints();
53
+ let score = constraints.evaluate_all(&sample_plan(vec![0, 1]));
54
+
55
+ assert_eq!(score.hard(), -56);
56
+ assert!(score.soft() < 0);
57
+ }
58
+
59
+ #[test]
60
+ fn stock_route_constraints_report_matching_routes() {
61
+ let constraints = crate::constraints::create_constraints();
62
+ let results = constraints.evaluate_each(&sample_plan(vec![0, 1]));
63
+ let match_count = |name: &str| {
64
+ results
65
+ .iter()
66
+ .find(|result| result.name == name)
67
+ .map(|result| result.match_count)
68
+ .unwrap_or_else(|| panic!("missing constraint result for {name}"))
69
+ };
70
+
71
+ assert_eq!(match_count("Balance Workload"), 1);
72
+ assert_eq!(match_count("Minimize Travel"), 1);
73
+ assert_eq!(match_count("Priority Slack"), 1);
74
+ assert_eq!(match_count("Required Parts"), 1);
75
+ assert_eq!(match_count("Shift Capacity"), 1);
76
+ assert_eq!(match_count("Territory Affinity"), 1);
77
+ }
78
+
79
+ fn sample_plan(visits: Vec<usize>) -> FieldServicePlan {
80
+ let locations = vec![
81
+ Location::new(
82
+ "loc-0",
83
+ "Hub",
84
+ "Hub".to_string(),
85
+ 45_700_000,
86
+ 9_670_000,
87
+ "depot".to_string(),
88
+ ),
89
+ Location::new(
90
+ "loc-1",
91
+ "Customer 1",
92
+ "Customer 1".to_string(),
93
+ 45_710_000,
94
+ 9_680_000,
95
+ "customer".to_string(),
96
+ ),
97
+ Location::new(
98
+ "loc-2",
99
+ "Customer 2",
100
+ "Customer 2".to_string(),
101
+ 45_720_000,
102
+ 9_690_000,
103
+ "customer".to_string(),
104
+ ),
105
+ ];
106
+ let service_visits = vec![
107
+ ServiceVisit::new(ServiceVisitInit {
108
+ id: "visit-0".to_string(),
109
+ name: "Boiler".to_string(),
110
+ customer: "Customer 1".to_string(),
111
+ location_idx: 1,
112
+ duration_minutes: 30,
113
+ earliest_minute: 510,
114
+ latest_minute: 540,
115
+ required_skill_mask: 0b001,
116
+ required_parts_mask: 0b010,
117
+ priority: 3,
118
+ territory: "center".to_string(),
119
+ }),
120
+ ServiceVisit::new(ServiceVisitInit {
121
+ id: "visit-1".to_string(),
122
+ name: "Lift".to_string(),
123
+ customer: "Customer 2".to_string(),
124
+ location_idx: 2,
125
+ duration_minutes: 45,
126
+ earliest_minute: 540,
127
+ latest_minute: 570,
128
+ required_skill_mask: 0b001,
129
+ required_parts_mask: 0b100,
130
+ priority: 2,
131
+ territory: "center".to_string(),
132
+ }),
133
+ ];
134
+ let travel_legs = row_major_legs(3);
135
+ let mut route = TechnicianRoute::new(TechnicianRouteInit {
136
+ id: "route-0".to_string(),
137
+ technician_id: "tech-0".to_string(),
138
+ technician_name: "Ada".to_string(),
139
+ color: "#2563eb".to_string(),
140
+ start_location_idx: 0,
141
+ end_location_idx: 0,
142
+ shift_start_minute: 480,
143
+ shift_end_minute: 585,
144
+ max_route_minutes: 90,
145
+ skill_mask: 0b001,
146
+ inventory_mask: 0b010,
147
+ territory: "center".to_string(),
148
+ });
149
+ route.visits = visits;
150
+
151
+ FieldServicePlan::new(locations, service_visits, travel_legs, vec![route])
152
+ }
153
+
154
+ fn row_major_legs(width: usize) -> Vec<TravelLeg> {
155
+ (0..width)
156
+ .flat_map(|from| {
157
+ (0..width).map(move |to| {
158
+ let same = from == to;
159
+ TravelLeg::new(TravelLegInit {
160
+ id: format!("leg-{from}-{to}"),
161
+ name: format!("leg-{from}-{to}"),
162
+ from_location_idx: from,
163
+ to_location_idx: to,
164
+ duration_seconds: if same { 0 } else { 600 },
165
+ distance_meters: if same { 0 } else { 2_000 },
166
+ reachable: true,
167
+ })
168
+ })
169
+ })
170
+ .collect()
171
+ }
src/constraints/shift_capacity.rs ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, FieldServicePlanConstraintStreams, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// HARD: the complete route must fit inside the technician shift and route cap.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
8
+ .technician_routes()
9
+ .filter(|route: &TechnicianRoute| route.route_overtime_minutes > 0)
10
+ .penalize(hard_weight(|route: &TechnicianRoute| {
11
+ HardSoftScore::of(route.route_overtime_minutes, 0)
12
+ }))
13
+ .named("Shift Capacity")
14
+ }
src/constraints/territory_affinity.rs ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, FieldServicePlanConstraintStreams, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// SOFT: prefer visits inside the technician's familiar territory.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
8
+ .technician_routes()
9
+ .filter(|route: &TechnicianRoute| route.route_territory_matches > 0)
10
+ .reward(|route: &TechnicianRoute| HardSoftScore::of(0, route.route_territory_matches * 25))
11
+ .named("Territory Affinity")
12
+ }
src/constraints/time_windows.rs ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, FieldServicePlanConstraintStreams, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// HARD: each visit must start no later than its latest service minute.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ ConstraintFactory::<FieldServicePlan, HardSoftScore>::new()
8
+ .technician_routes()
9
+ .filter(|route: &TechnicianRoute| route.route_late_minutes > 0)
10
+ .penalize(hard_weight(|route: &TechnicianRoute| {
11
+ HardSoftScore::of(route.route_late_minutes, 0)
12
+ }))
13
+ .named("Time Windows")
14
+ }
src/data/bergamo_catalog.rs ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::Location;
2
+
3
+ #[derive(Clone, Copy)]
4
+ pub(super) struct LocationSeed {
5
+ pub id: &'static str,
6
+ pub label: &'static str,
7
+ pub lat: f64,
8
+ pub lng: f64,
9
+ pub territory: &'static str,
10
+ }
11
+
12
+ impl LocationSeed {
13
+ pub(super) fn to_location(self, kind: &'static str) -> Location {
14
+ Location::new(
15
+ self.id,
16
+ self.label,
17
+ self.label.to_string(),
18
+ coord_e6(self.lat),
19
+ coord_e6(self.lng),
20
+ kind.to_string(),
21
+ )
22
+ }
23
+ }
24
+
25
+ #[derive(Clone, Copy)]
26
+ pub(super) struct VisitProfile {
27
+ pub name: &'static str,
28
+ pub duration_minutes: i32,
29
+ pub earliest_minute: i32,
30
+ pub latest_minute: i32,
31
+ pub required_skill_mask: i64,
32
+ pub required_parts_mask: i64,
33
+ pub priority: i32,
34
+ }
35
+
36
+ #[derive(Clone, Copy)]
37
+ pub(super) struct TechnicianSeed {
38
+ pub id: &'static str,
39
+ pub name: &'static str,
40
+ pub color: &'static str,
41
+ pub start_location_idx: usize,
42
+ pub end_location_idx: usize,
43
+ pub skill_mask: i64,
44
+ pub inventory_mask: i64,
45
+ pub territory: &'static str,
46
+ }
47
+
48
+ pub(super) const SKILL_HVAC: i64 = 0b0001;
49
+ pub(super) const SKILL_ELECTRICAL: i64 = 0b0010;
50
+ pub(super) const SKILL_PLUMBING: i64 = 0b0100;
51
+ pub(super) const SKILL_ELEVATOR: i64 = 0b1000;
52
+
53
+ pub(super) const PART_FILTERS: i64 = 0b0001;
54
+ pub(super) const PART_RELAYS: i64 = 0b0010;
55
+ pub(super) const PART_VALVES: i64 = 0b0100;
56
+ pub(super) const PART_SENSORS: i64 = 0b1000;
57
+
58
+ fn coord_e6(value: f64) -> i32 {
59
+ (value * 1_000_000.0).round() as i32
60
+ }
src/data/bergamo_locations.rs ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Static Bergamo depots and customer sites used by the `STANDARD` dataset.
2
+ //!
3
+ //! These are input facts, not solver decisions. SolverForge later changes only
4
+ //! the visit order inside technician routes.
5
+
6
+ use super::bergamo_catalog::LocationSeed;
7
+
8
+ pub(super) const DEPOTS: &[LocationSeed] = &[
9
+ LocationSeed {
10
+ id: "depot-ops",
11
+ label: "Bergamo Operations Hub",
12
+ lat: 45.6954,
13
+ lng: 9.6703,
14
+ territory: "center",
15
+ },
16
+ LocationSeed {
17
+ id: "depot-east",
18
+ label: "Seriate Parts Locker",
19
+ lat: 45.6835,
20
+ lng: 9.7210,
21
+ territory: "east",
22
+ },
23
+ ];
24
+
25
+ pub(super) const SERVICE_LOCATIONS: &[LocationSeed] = &[
26
+ LocationSeed {
27
+ id: "loc-citta-alta",
28
+ label: "Citta Alta heating fault",
29
+ lat: 45.7036,
30
+ lng: 9.6627,
31
+ territory: "north",
32
+ },
33
+ LocationSeed {
34
+ id: "loc-borgo-palazzo",
35
+ label: "Borgo Palazzo refrigeration",
36
+ lat: 45.6903,
37
+ lng: 9.6909,
38
+ territory: "east",
39
+ },
40
+ LocationSeed {
41
+ id: "loc-stazione",
42
+ label: "Station kiosk power",
43
+ lat: 45.6900,
44
+ lng: 9.6750,
45
+ territory: "center",
46
+ },
47
+ LocationSeed {
48
+ id: "loc-longuelo",
49
+ label: "Longuelo pump service",
50
+ lat: 45.6982,
51
+ lng: 9.6377,
52
+ territory: "west",
53
+ },
54
+ LocationSeed {
55
+ id: "loc-redona",
56
+ label: "Redona lift inspection",
57
+ lat: 45.7107,
58
+ lng: 9.6999,
59
+ territory: "north",
60
+ },
61
+ LocationSeed {
62
+ id: "loc-celadina",
63
+ label: "Celadina controls alarm",
64
+ lat: 45.6815,
65
+ lng: 9.7056,
66
+ territory: "east",
67
+ },
68
+ LocationSeed {
69
+ id: "loc-valtesse",
70
+ label: "Valtesse boiler reset",
71
+ lat: 45.7202,
72
+ lng: 9.6736,
73
+ territory: "north",
74
+ },
75
+ LocationSeed {
76
+ id: "loc-colognola",
77
+ label: "Colognola valve leak",
78
+ lat: 45.6767,
79
+ lng: 9.6469,
80
+ territory: "south",
81
+ },
82
+ LocationSeed {
83
+ id: "loc-malpensata",
84
+ label: "Malpensata sensor swap",
85
+ lat: 45.6840,
86
+ lng: 9.6687,
87
+ territory: "south",
88
+ },
89
+ LocationSeed {
90
+ id: "loc-seriate",
91
+ label: "Seriate medical cooler",
92
+ lat: 45.6856,
93
+ lng: 9.7242,
94
+ territory: "east",
95
+ },
96
+ LocationSeed {
97
+ id: "loc-gorle",
98
+ label: "Gorle access control",
99
+ lat: 45.7014,
100
+ lng: 9.7138,
101
+ territory: "east",
102
+ },
103
+ LocationSeed {
104
+ id: "loc-treviglio-road",
105
+ label: "Azzano workshop air unit",
106
+ lat: 45.6579,
107
+ lng: 9.6734,
108
+ territory: "south",
109
+ },
110
+ LocationSeed {
111
+ id: "loc-monterosso",
112
+ label: "Monterosso lift callout",
113
+ lat: 45.7161,
114
+ lng: 9.6905,
115
+ territory: "north",
116
+ },
117
+ LocationSeed {
118
+ id: "loc-loreto",
119
+ label: "Loreto electrical board",
120
+ lat: 45.6995,
121
+ lng: 9.6517,
122
+ territory: "west",
123
+ },
124
+ LocationSeed {
125
+ id: "loc-stezzano",
126
+ label: "Stezzano retail HVAC",
127
+ lat: 45.6508,
128
+ lng: 9.6534,
129
+ territory: "south",
130
+ },
131
+ LocationSeed {
132
+ id: "loc-grumello",
133
+ label: "Grumello pressure issue",
134
+ lat: 45.6888,
135
+ lng: 9.6275,
136
+ territory: "west",
137
+ },
138
+ LocationSeed {
139
+ id: "loc-orio",
140
+ label: "Orio terminal chiller",
141
+ lat: 45.6689,
142
+ lng: 9.7044,
143
+ territory: "south",
144
+ },
145
+ LocationSeed {
146
+ id: "loc-ranica",
147
+ label: "Ranica municipal lift",
148
+ lat: 45.7241,
149
+ lng: 9.7133,
150
+ territory: "north",
151
+ },
152
+ LocationSeed {
153
+ id: "loc-torre-boldone",
154
+ label: "Torre Boldone boiler",
155
+ lat: 45.7178,
156
+ lng: 9.7075,
157
+ territory: "north",
158
+ },
159
+ LocationSeed {
160
+ id: "loc-villaggio-sposi",
161
+ label: "Villaggio Sposi pump",
162
+ lat: 45.6901,
163
+ lng: 9.6365,
164
+ territory: "west",
165
+ },
166
+ LocationSeed {
167
+ id: "loc-dalmine",
168
+ label: "Dalmine line sensor",
169
+ lat: 45.6482,
170
+ lng: 9.6061,
171
+ territory: "west",
172
+ },
173
+ LocationSeed {
174
+ id: "loc-alzano",
175
+ label: "Alzano Lombardo relay",
176
+ lat: 45.7362,
177
+ lng: 9.7271,
178
+ territory: "north",
179
+ },
180
+ LocationSeed {
181
+ id: "loc-ponte-san-pietro",
182
+ label: "Ponte San Pietro valve",
183
+ lat: 45.7001,
184
+ lng: 9.5908,
185
+ territory: "west",
186
+ },
187
+ LocationSeed {
188
+ id: "loc-scanzo",
189
+ label: "Scanzorosciate cooler",
190
+ lat: 45.7105,
191
+ lng: 9.7354,
192
+ territory: "east",
193
+ },
194
+ ];
src/data/bergamo_profiles.rs ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::bergamo_catalog::{
2
+ VisitProfile, PART_RELAYS, PART_SENSORS, PART_VALVES, SKILL_ELECTRICAL, SKILL_ELEVATOR,
3
+ SKILL_HVAC, SKILL_PLUMBING,
4
+ };
5
+
6
+ pub(super) const VISIT_PROFILES: &[VisitProfile] = &[
7
+ VisitProfile {
8
+ name: "Boiler restart",
9
+ duration_minutes: 35,
10
+ earliest_minute: 8 * 60,
11
+ latest_minute: 18 * 60,
12
+ required_skill_mask: SKILL_HVAC,
13
+ required_parts_mask: PART_SENSORS,
14
+ priority: 4,
15
+ },
16
+ VisitProfile {
17
+ name: "Refrigeration diagnosis",
18
+ duration_minutes: 45,
19
+ earliest_minute: 9 * 60,
20
+ latest_minute: 18 * 60,
21
+ required_skill_mask: SKILL_HVAC | SKILL_ELECTRICAL,
22
+ required_parts_mask: PART_RELAYS,
23
+ priority: 5,
24
+ },
25
+ VisitProfile {
26
+ name: "Electrical board check",
27
+ duration_minutes: 30,
28
+ earliest_minute: 8 * 60 + 30,
29
+ latest_minute: 18 * 60,
30
+ required_skill_mask: SKILL_ELECTRICAL,
31
+ required_parts_mask: PART_RELAYS,
32
+ priority: 3,
33
+ },
34
+ VisitProfile {
35
+ name: "Pump service",
36
+ duration_minutes: 50,
37
+ earliest_minute: 10 * 60,
38
+ latest_minute: 18 * 60,
39
+ required_skill_mask: SKILL_PLUMBING,
40
+ required_parts_mask: PART_VALVES,
41
+ priority: 3,
42
+ },
43
+ VisitProfile {
44
+ name: "Lift safety inspection",
45
+ duration_minutes: 60,
46
+ earliest_minute: 11 * 60,
47
+ latest_minute: 18 * 60,
48
+ required_skill_mask: SKILL_ELEVATOR | SKILL_ELECTRICAL,
49
+ required_parts_mask: PART_SENSORS,
50
+ priority: 4,
51
+ },
52
+ VisitProfile {
53
+ name: "Controls alarm reset",
54
+ duration_minutes: 25,
55
+ earliest_minute: 13 * 60,
56
+ latest_minute: 18 * 60,
57
+ required_skill_mask: SKILL_ELECTRICAL,
58
+ required_parts_mask: PART_SENSORS,
59
+ priority: 2,
60
+ },
61
+ ];
src/data/bergamo_technicians.rs ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::bergamo_catalog::{
2
+ TechnicianSeed, PART_FILTERS, PART_RELAYS, PART_SENSORS, PART_VALVES, SKILL_ELECTRICAL,
3
+ SKILL_ELEVATOR, SKILL_HVAC, SKILL_PLUMBING,
4
+ };
5
+
6
+ pub(super) const TECHNICIANS: &[TechnicianSeed] = &[
7
+ TechnicianSeed {
8
+ id: "tech-ada",
9
+ name: "Ada Romano",
10
+ color: "#2563eb",
11
+ start_location_idx: 0,
12
+ end_location_idx: 0,
13
+ skill_mask: ALL_SKILLS,
14
+ inventory_mask: ALL_PARTS,
15
+ territory: "center",
16
+ },
17
+ TechnicianSeed {
18
+ id: "tech-marco",
19
+ name: "Marco Bianchi",
20
+ color: "#059669",
21
+ start_location_idx: 1,
22
+ end_location_idx: 1,
23
+ skill_mask: ALL_SKILLS,
24
+ inventory_mask: ALL_PARTS,
25
+ territory: "east",
26
+ },
27
+ TechnicianSeed {
28
+ id: "tech-elena",
29
+ name: "Elena Conti",
30
+ color: "#d97706",
31
+ start_location_idx: 0,
32
+ end_location_idx: 0,
33
+ skill_mask: ALL_SKILLS,
34
+ inventory_mask: ALL_PARTS,
35
+ territory: "north",
36
+ },
37
+ TechnicianSeed {
38
+ id: "tech-paolo",
39
+ name: "Paolo Gatti",
40
+ color: "#be123c",
41
+ start_location_idx: 0,
42
+ end_location_idx: 0,
43
+ skill_mask: ALL_SKILLS,
44
+ inventory_mask: ALL_PARTS,
45
+ territory: "west",
46
+ },
47
+ TechnicianSeed {
48
+ id: "tech-sara",
49
+ name: "Sara Ferri",
50
+ color: "#7c3aed",
51
+ start_location_idx: 1,
52
+ end_location_idx: 1,
53
+ skill_mask: ALL_SKILLS,
54
+ inventory_mask: ALL_PARTS,
55
+ territory: "south",
56
+ },
57
+ TechnicianSeed {
58
+ id: "tech-luca",
59
+ name: "Luca Moretti",
60
+ color: "#0f766e",
61
+ start_location_idx: 0,
62
+ end_location_idx: 0,
63
+ skill_mask: ALL_SKILLS,
64
+ inventory_mask: ALL_PARTS,
65
+ territory: "east",
66
+ },
67
+ ];
68
+
69
+ const ALL_SKILLS: i64 = SKILL_ELECTRICAL | SKILL_ELEVATOR | SKILL_HVAC | SKILL_PLUMBING;
70
+ const ALL_PARTS: i64 = PART_FILTERS | PART_RELAYS | PART_SENSORS | PART_VALVES;
src/data/data_seed.rs ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Deterministic Bergamo demo-data builder and routing preparation.
2
+ //!
3
+ //! The public app starts from ordinary domain facts: locations, service visits,
4
+ //! technician routes, and travel legs. Road-network preparation enriches those
5
+ //! facts before solving, but the solver still receives a normal
6
+ //! `FieldServicePlan`.
7
+
8
+ use std::fmt;
9
+ use std::path::PathBuf;
10
+ use std::str::FromStr;
11
+ use std::time::Duration;
12
+
13
+ use solverforge_maps::{
14
+ BoundingBox, Coord, NetworkConfig, NetworkRef, RoadNetwork, RoutingError, UNREACHABLE,
15
+ };
16
+
17
+ use super::bergamo_locations::{DEPOTS, SERVICE_LOCATIONS};
18
+ use super::bergamo_profiles::VISIT_PROFILES;
19
+ use super::bergamo_technicians::TECHNICIANS;
20
+ use crate::domain::{
21
+ FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
22
+ TechnicianRouteInit, TravelLeg, TravelLegInit,
23
+ };
24
+
25
+ const BERGAMO_BBOX: BoundingBox = BoundingBox {
26
+ min_lat: 45.64,
27
+ min_lng: 9.58,
28
+ max_lat: 45.75,
29
+ max_lng: 9.78,
30
+ };
31
+
32
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
33
+ pub enum DemoData {
34
+ Standard,
35
+ }
36
+
37
+ #[derive(Debug)]
38
+ pub enum DemoDataError {
39
+ Routing(RoutingError),
40
+ }
41
+
42
+ impl fmt::Display for DemoDataError {
43
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44
+ match self {
45
+ Self::Routing(error) => write!(f, "Bergamo OSM routing data is unavailable: {error}"),
46
+ }
47
+ }
48
+ }
49
+
50
+ impl std::error::Error for DemoDataError {}
51
+
52
+ impl From<RoutingError> for DemoDataError {
53
+ fn from(error: RoutingError) -> Self {
54
+ Self::Routing(error)
55
+ }
56
+ }
57
+
58
+ const AVAILABLE_DEMO_DATA: &[DemoData] = &[DemoData::Standard];
59
+ const DEFAULT_DEMO_DATA: DemoData = DemoData::Standard;
60
+
61
+ pub fn default_demo_data() -> DemoData {
62
+ DEFAULT_DEMO_DATA
63
+ }
64
+
65
+ /// Returns the complete list of public demo ids exposed through `/demo-data`.
66
+ pub fn available_demo_data() -> &'static [DemoData] {
67
+ AVAILABLE_DEMO_DATA
68
+ }
69
+
70
+ impl DemoData {
71
+ pub fn id(self) -> &'static str {
72
+ match self {
73
+ DemoData::Standard => "STANDARD",
74
+ }
75
+ }
76
+
77
+ pub fn default_demo_data() -> Self {
78
+ default_demo_data()
79
+ }
80
+
81
+ pub fn available_demo_data() -> &'static [Self] {
82
+ available_demo_data()
83
+ }
84
+
85
+ fn technician_count(self) -> usize {
86
+ match self {
87
+ Self::Standard => 6,
88
+ }
89
+ }
90
+
91
+ fn visit_count(self) -> usize {
92
+ match self {
93
+ Self::Standard => SERVICE_LOCATIONS.len() * 2,
94
+ }
95
+ }
96
+ }
97
+
98
+ impl FromStr for DemoData {
99
+ type Err = ();
100
+
101
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
102
+ match s.to_ascii_uppercase().as_str() {
103
+ "STANDARD" => Ok(DemoData::Standard),
104
+ _ => Err(()),
105
+ }
106
+ }
107
+ }
108
+
109
+ /// Builds the requested demo plan and prepares road-network travel facts.
110
+ pub async fn generate(demo: DemoData) -> Result<FieldServicePlan, DemoDataError> {
111
+ // The initial demo response must be fast and deterministic, so it ships only
112
+ // seed self-legs. Full road-network legs are prepared when a solve starts.
113
+ let locations = build_locations(demo);
114
+ let travel_legs = build_seed_travel_legs(locations.len());
115
+ let service_visits = build_service_visits(demo);
116
+ let technician_routes = build_technician_routes(demo);
117
+
118
+ Ok(FieldServicePlan::new(
119
+ locations,
120
+ service_visits,
121
+ travel_legs,
122
+ technician_routes,
123
+ ))
124
+ }
125
+
126
+ /// Replaces seed travel legs with road-network durations and distances.
127
+ pub async fn prepare_routing(plan: &mut FieldServicePlan) -> Result<(), DemoDataError> {
128
+ // This is the expensive OSM-backed step. It runs once per submitted plan so
129
+ // every candidate route move is scored against a stable travel matrix.
130
+ let coords = plan
131
+ .locations
132
+ .iter()
133
+ .map(|location| Coord::new(location.lat(), location.lng()))
134
+ .collect::<Vec<_>>();
135
+ let network = load_network().await?;
136
+ let matrix = network.compute_matrix(&coords, None).await;
137
+ plan.travel_legs = build_travel_legs(&matrix, coords.len());
138
+ plan.normalize();
139
+ Ok(())
140
+ }
141
+
142
+ pub async fn load_network() -> Result<NetworkRef, DemoDataError> {
143
+ RoadNetwork::load_or_fetch(&BERGAMO_BBOX, &network_config(), None)
144
+ .await
145
+ .map_err(DemoDataError::from)
146
+ }
147
+
148
+ pub fn network_config() -> NetworkConfig {
149
+ NetworkConfig::default()
150
+ .cache_dir(PathBuf::from(".osm_cache/field-service-routing/bergamo"))
151
+ .connect_timeout(Duration::from_secs(10))
152
+ .read_timeout(Duration::from_secs(30))
153
+ .overpass_max_retries(1)
154
+ .overpass_retry_backoff(Duration::from_secs(2))
155
+ }
156
+
157
+ fn build_locations(demo: DemoData) -> Vec<Location> {
158
+ let service_location_count = demo.visit_count().min(SERVICE_LOCATIONS.len());
159
+
160
+ DEPOTS
161
+ .iter()
162
+ .map(|seed| seed.to_location("depot"))
163
+ .chain(
164
+ SERVICE_LOCATIONS
165
+ .iter()
166
+ .take(service_location_count)
167
+ .map(|seed| seed.to_location("customer")),
168
+ )
169
+ .collect()
170
+ }
171
+
172
+ fn build_service_visits(demo: DemoData) -> Vec<ServiceVisit> {
173
+ (0..demo.visit_count())
174
+ .map(|idx| {
175
+ let seed = &SERVICE_LOCATIONS[idx % SERVICE_LOCATIONS.len()];
176
+ let profile = VISIT_PROFILES[idx % VISIT_PROFILES.len()];
177
+ ServiceVisit::new(ServiceVisitInit {
178
+ id: format!("visit-{idx:02}"),
179
+ name: profile.name.to_string(),
180
+ customer: seed.label.to_string(),
181
+ location_idx: DEPOTS.len() + (idx % SERVICE_LOCATIONS.len()),
182
+ duration_minutes: profile.duration_minutes,
183
+ earliest_minute: profile.earliest_minute,
184
+ latest_minute: profile.latest_minute,
185
+ required_skill_mask: profile.required_skill_mask,
186
+ required_parts_mask: profile.required_parts_mask,
187
+ priority: profile.priority,
188
+ territory: seed.territory.to_string(),
189
+ })
190
+ })
191
+ .collect()
192
+ }
193
+
194
+ fn build_technician_routes(demo: DemoData) -> Vec<TechnicianRoute> {
195
+ TECHNICIANS
196
+ .iter()
197
+ .take(demo.technician_count())
198
+ .enumerate()
199
+ .map(|(idx, seed)| {
200
+ TechnicianRoute::new(TechnicianRouteInit {
201
+ id: format!("route-{idx:02}"),
202
+ technician_id: seed.id.to_string(),
203
+ technician_name: seed.name.to_string(),
204
+ color: seed.color.to_string(),
205
+ start_location_idx: seed.start_location_idx,
206
+ end_location_idx: seed.end_location_idx,
207
+ shift_start_minute: 8 * 60,
208
+ shift_end_minute: 18 * 60,
209
+ max_route_minutes: 10 * 60,
210
+ skill_mask: seed.skill_mask,
211
+ inventory_mask: seed.inventory_mask,
212
+ territory: seed.territory.to_string(),
213
+ })
214
+ })
215
+ .collect()
216
+ }
217
+
218
+ fn build_seed_travel_legs(width: usize) -> Vec<TravelLeg> {
219
+ (0..width)
220
+ .map(|idx| {
221
+ TravelLeg::new(TravelLegInit {
222
+ id: format!("leg-{idx:02}-{idx:02}"),
223
+ name: format!("leg-{idx:02}-{idx:02}"),
224
+ from_location_idx: idx,
225
+ to_location_idx: idx,
226
+ duration_seconds: 0,
227
+ distance_meters: 0,
228
+ reachable: true,
229
+ })
230
+ })
231
+ .collect()
232
+ }
233
+
234
+ fn build_travel_legs(matrix: &solverforge_maps::TravelTimeMatrix, width: usize) -> Vec<TravelLeg> {
235
+ let mut legs = Vec::with_capacity(width * width);
236
+
237
+ for from in 0..width {
238
+ for to in 0..width {
239
+ let (duration_seconds, distance_meters, reachable) = if from == to {
240
+ (0, 0, true)
241
+ } else {
242
+ let matrix_duration = matrix.get(from, to).unwrap_or(UNREACHABLE);
243
+ let matrix_distance = matrix.distance_meters(from, to).unwrap_or(UNREACHABLE);
244
+ if matrix_duration == UNREACHABLE || matrix_distance == UNREACHABLE {
245
+ (0, 0, false)
246
+ } else {
247
+ (matrix_duration, matrix_distance, true)
248
+ }
249
+ };
250
+
251
+ legs.push(TravelLeg::new(TravelLegInit {
252
+ id: format!("leg-{from:02}-{to:02}"),
253
+ name: format!("leg-{from:02}-{to:02}"),
254
+ from_location_idx: from,
255
+ to_location_idx: to,
256
+ duration_seconds,
257
+ distance_meters,
258
+ reachable,
259
+ }));
260
+ }
261
+ }
262
+
263
+ legs
264
+ }
265
+
266
+ #[cfg(test)]
267
+ mod tests {
268
+ use super::*;
269
+
270
+ #[test]
271
+ fn generated_technician_routes_start_without_assigned_visits() {
272
+ for demo in DemoData::available_demo_data() {
273
+ let routes = build_technician_routes(*demo);
274
+
275
+ assert!(!routes.is_empty());
276
+ assert!(routes.iter().all(|route| route.visits.is_empty()));
277
+ }
278
+ }
279
+
280
+ #[tokio::test]
281
+ async fn generated_seed_plan_has_only_identity_travel_legs() {
282
+ let plan = generate(DemoData::Standard).await.unwrap();
283
+
284
+ assert_eq!(plan.travel_legs.len(), plan.locations.len());
285
+ assert!(plan.travel_legs.iter().enumerate().all(|(idx, leg)| {
286
+ leg.from_location_idx == idx
287
+ && leg.to_location_idx == idx
288
+ && leg.duration_seconds == 0
289
+ && leg.distance_meters == 0
290
+ && leg.reachable
291
+ }));
292
+ }
293
+ }
src/data/mod.rs ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Stable demo-data boundary for the FSR app.
2
+ //!
3
+ //! Other layers import from `crate::data` instead of city-specific files. That
4
+ //! keeps routing preparation and demo-id parsing behind one small interface.
5
+
6
+ mod bergamo_catalog;
7
+ mod bergamo_locations;
8
+ mod bergamo_profiles;
9
+ mod bergamo_technicians;
10
+ mod data_seed;
11
+
12
+ pub use data_seed::{
13
+ available_demo_data, default_demo_data, generate, load_network, prepare_routing, DemoData,
14
+ DemoDataError,
15
+ };
src/domain/field_service_plan.rs ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Planning solution for the field-service routing problem.
2
+ //!
3
+ //! `FieldServicePlan` is both the input to SolverForge and the domain value
4
+ //! converted to JSON snapshots after solving. Facts stay read-only; technician
5
+ //! routes carry the mutable visit list.
6
+
7
+ use serde::{Deserialize, Deserializer, Serialize};
8
+ use solverforge::prelude::*;
9
+
10
+ // @solverforge:begin solution-imports
11
+ use super::Location;
12
+ use super::route_metrics::route_stats;
13
+ use super::ServiceVisit;
14
+ use super::TechnicianRoute;
15
+ use super::TravelLeg;
16
+ // @solverforge:end solution-imports
17
+
18
+ /// Full planning solution passed to the SolverForge runtime and HTTP API.
19
+ ///
20
+ /// The first three collections are read-only facts. `technician_routes` is the
21
+ /// planning entity collection because each route owns the mutable visit list.
22
+ #[planning_solution(
23
+ constraints = "crate::constraints::create_constraints",
24
+ solver_toml = "../../solver.toml"
25
+ )]
26
+ #[shadow_variable_updates(
27
+ list_owner = "technician_routes",
28
+ post_update_listener = "refresh_technician_route_shadows"
29
+ )]
30
+ #[derive(Serialize)]
31
+ pub struct FieldServicePlan {
32
+ // @solverforge:begin solution-collections
33
+ /// All depots and customer sites, addressed by vector index from visits and
34
+ /// route endpoints.
35
+ #[problem_fact_collection]
36
+ pub locations: Vec<Location>,
37
+ /// Customer jobs that must be inserted into technician routes.
38
+ #[problem_fact_collection]
39
+ pub service_visits: Vec<ServiceVisit>,
40
+ /// Directed travel matrix used by constraints and route geometry.
41
+ #[problem_fact_collection]
42
+ pub travel_legs: Vec<TravelLeg>,
43
+ /// Route entities whose `visits` lists are changed by the solver.
44
+ #[planning_entity_collection]
45
+ pub technician_routes: Vec<TechnicianRoute>,
46
+ // @solverforge:end solution-collections
47
+ #[planning_score]
48
+ pub score: Option<HardSoftScore>,
49
+ }
50
+
51
+ impl<'de> Deserialize<'de> for FieldServicePlan {
52
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53
+ where
54
+ D: Deserializer<'de>,
55
+ {
56
+ #[derive(Deserialize)]
57
+ struct RawFieldServicePlan {
58
+ locations: Vec<Location>,
59
+ service_visits: Vec<ServiceVisit>,
60
+ travel_legs: Vec<TravelLeg>,
61
+ technician_routes: Vec<TechnicianRoute>,
62
+ #[serde(default)]
63
+ score: Option<HardSoftScore>,
64
+ }
65
+
66
+ let raw = RawFieldServicePlan::deserialize(deserializer)?;
67
+ let mut plan = Self {
68
+ locations: raw.locations,
69
+ service_visits: raw.service_visits,
70
+ travel_legs: raw.travel_legs,
71
+ technician_routes: raw.technician_routes,
72
+ score: raw.score,
73
+ };
74
+ plan.normalize();
75
+ Ok(plan)
76
+ }
77
+ }
78
+
79
+ impl FieldServicePlan {
80
+ /// Builds a plan from immutable facts and initially empty route entities.
81
+ #[rustfmt::skip]
82
+ pub fn new(
83
+ // @solverforge:begin solution-constructor-params
84
+ locations: Vec<Location>,
85
+ service_visits: Vec<ServiceVisit>,
86
+ travel_legs: Vec<TravelLeg>,
87
+ technician_routes: Vec<TechnicianRoute>,
88
+ // @solverforge:end solution-constructor-params
89
+ ) -> Self {
90
+ let mut plan = Self {
91
+ // @solverforge:begin solution-constructor-init
92
+ locations,
93
+ service_visits,
94
+ travel_legs,
95
+ technician_routes,
96
+ // @solverforge:end solution-constructor-init
97
+ score: None,
98
+ };
99
+ plan.normalize();
100
+ plan
101
+ }
102
+
103
+ /// Restores transient indexes and derived route shadow fields after construction or decoding.
104
+ pub fn normalize(&mut self) {
105
+ for (idx, visit) in self.service_visits.iter_mut().enumerate() {
106
+ visit.index = idx;
107
+ }
108
+
109
+ for route_idx in 0..self.technician_routes.len() {
110
+ self.refresh_technician_route_shadows(route_idx);
111
+ }
112
+ }
113
+
114
+ /// List-variable post-update hook used by SolverForge shadow variables.
115
+ pub fn refresh_technician_route_shadows(&mut self, route_idx: usize) {
116
+ let stats = {
117
+ let Some(route) = self.technician_routes.get(route_idx) else {
118
+ return;
119
+ };
120
+ route_stats(self, route)
121
+ };
122
+
123
+ if let Some(route) = self.technician_routes.get_mut(route_idx) {
124
+ route.apply_route_stats(stats);
125
+ }
126
+ }
127
+ }
src/domain/location.rs ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use solverforge::prelude::*;
3
+
4
+ /// Depot or customer site used by the routing model.
5
+ ///
6
+ /// SolverForge treats a `Location` as read-only problem data. Routes refer to
7
+ /// locations by vector index so constraints and map rendering can cheaply look
8
+ /// up coordinates without copying place records into every visit.
9
+ #[problem_fact]
10
+ #[derive(Serialize, Deserialize)]
11
+ pub struct Location {
12
+ #[planning_id]
13
+ pub id: String,
14
+ pub name: String,
15
+ pub label: String,
16
+ pub lat_e6: i32,
17
+ pub lng_e6: i32,
18
+ pub kind: String,
19
+ }
20
+
21
+ impl Location {
22
+ /// Builds one location fact from seed data or transport input.
23
+ pub fn new(
24
+ id: impl Into<String>,
25
+ name: impl Into<String>,
26
+ label: String,
27
+ lat_e6: i32,
28
+ lng_e6: i32,
29
+ kind: String,
30
+ ) -> Self {
31
+ Self {
32
+ id: id.into(),
33
+ name: name.into(),
34
+ label,
35
+ lat_e6,
36
+ lng_e6,
37
+ kind,
38
+ }
39
+ }
40
+
41
+ /// Returns latitude in degrees from the integer microdegree storage format.
42
+ pub fn lat(&self) -> f64 {
43
+ f64::from(self.lat_e6) / 1_000_000.0
44
+ }
45
+
46
+ /// Returns longitude in degrees from the integer microdegree storage format.
47
+ pub fn lng(&self) -> f64 {
48
+ f64::from(self.lng_e6) / 1_000_000.0
49
+ }
50
+ }
51
+
52
+ #[cfg(test)]
53
+ mod tests {
54
+ use super::*;
55
+
56
+ #[test]
57
+ fn test_location_construction() {
58
+ let fact = Location::new(
59
+ "test-id",
60
+ "test",
61
+ "test".to_string(),
62
+ 0,
63
+ 0,
64
+ "test".to_string(),
65
+ );
66
+ assert_eq!(fact.id, "test-id");
67
+ assert_eq!(fact.name, "test");
68
+ let _ = &fact.label;
69
+ let _ = &fact.lat_e6;
70
+ let _ = &fact.lng_e6;
71
+ let _ = &fact.kind;
72
+ }
73
+ }
src/domain/mod.rs ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Planning-model manifest and domain-layer exports.
2
+ //!
3
+ //! `planning_model!` is the SolverForge boundary for this app. Keep the exports
4
+ //! in the same conceptual order as `solverforge.app.toml`: facts, planning
5
+ //! entity, then solution.
6
+
7
+ solverforge::planning_model! {
8
+ root = "src/domain";
9
+
10
+ // @solverforge:begin domain-exports
11
+ mod location;
12
+ mod service_visit;
13
+ mod travel_leg;
14
+ mod technician_route;
15
+ mod field_service_plan;
16
+
17
+ pub use location::Location;
18
+ pub use service_visit::ServiceVisit;
19
+ pub use service_visit::ServiceVisitInit;
20
+ pub use travel_leg::TravelLeg;
21
+ pub use travel_leg::TravelLegInit;
22
+ pub use technician_route::TechnicianRoute;
23
+ pub use technician_route::TechnicianRouteInit;
24
+ pub use field_service_plan::FieldServicePlan;
25
+ pub use field_service_plan::FieldServicePlanConstraintStreams;
26
+ // @solverforge:end domain-exports
27
+
28
+ pub mod route_metrics;
29
+ }
src/domain/route_metrics.rs ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Shared route measurements stored as technician-route shadow values.
2
+ //!
3
+ //! SolverForge calls each stock constraint separately, but the business
4
+ //! concepts overlap: travel, time windows, skills, parts, overtime, and priority
5
+ //! slack all require walking the same ordered visit list. This module
6
+ //! centralizes that walk so route entities can expose simple shadow fields to
7
+ //! the constraint builders.
8
+
9
+ use crate::domain::{FieldServicePlan, ServiceVisit, TechnicianRoute, TravelLeg};
10
+
11
+ /// Aggregated measurements for one technician route.
12
+ ///
13
+ /// Individual constraints reuse this struct so each business rule can stay
14
+ /// small. For example, the time-window constraint reads `late_minutes`, while
15
+ /// the travel minimization rule reads `travel_seconds` and `distance_meters`.
16
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
17
+ pub struct RouteStats {
18
+ pub invalid_visits: i64,
19
+ pub valid_visits: i64,
20
+ pub scored_travel_legs: i64,
21
+ pub unreachable_legs: i64,
22
+ pub missing_skill_visits: i64,
23
+ pub missing_part_visits: i64,
24
+ pub late_visits: i64,
25
+ pub late_minutes: i64,
26
+ pub overtime_minutes: i64,
27
+ pub travel_seconds: i64,
28
+ pub distance_meters: i64,
29
+ pub service_minutes: i64,
30
+ pub waiting_minutes: i64,
31
+ pub route_minutes: i64,
32
+ pub finish_minute: i32,
33
+ pub territory_matches: i64,
34
+ pub priority_slack: i64,
35
+ }
36
+
37
+ #[derive(Debug, Clone, Copy)]
38
+ struct VisitTiming {
39
+ visit_idx: usize,
40
+ service_start: i32,
41
+ }
42
+
43
+ pub fn route_stats(plan: &FieldServicePlan, route: &TechnicianRoute) -> RouteStats {
44
+ let mut stats = RouteStats {
45
+ finish_minute: route.shift_start_minute,
46
+ ..RouteStats::default()
47
+ };
48
+ let mut clock = route.shift_start_minute;
49
+ let mut previous_location = route.start_location_idx;
50
+ let mut timings = Vec::with_capacity(route.visits.len());
51
+
52
+ // Walk the route in visit order. This mirrors how a technician would drive:
53
+ // depot to first visit, visit to visit, then back to the end depot.
54
+ for &visit_idx in &route.visits {
55
+ let Some(visit) = plan.service_visits.get(visit_idx) else {
56
+ stats.invalid_visits += 1;
57
+ continue;
58
+ };
59
+ stats.valid_visits += 1;
60
+
61
+ apply_leg(
62
+ plan,
63
+ previous_location,
64
+ visit.location_idx,
65
+ &mut clock,
66
+ &mut stats,
67
+ );
68
+
69
+ // Waiting is allowed and soft-neutral; lateness is a hard feasibility
70
+ // problem scored by the time-window constraint.
71
+ if clock < visit.earliest_minute {
72
+ stats.waiting_minutes += i64::from(visit.earliest_minute - clock);
73
+ clock = visit.earliest_minute;
74
+ }
75
+ if clock > visit.latest_minute {
76
+ stats.late_visits += 1;
77
+ stats.late_minutes += i64::from(clock - visit.latest_minute);
78
+ }
79
+
80
+ if !mask_contains(route.skill_mask, visit.required_skill_mask) {
81
+ stats.missing_skill_visits += 1;
82
+ }
83
+ if !mask_contains(route.inventory_mask, visit.required_parts_mask) {
84
+ stats.missing_part_visits += 1;
85
+ }
86
+ if route.territory == visit.territory {
87
+ stats.territory_matches += 1;
88
+ }
89
+
90
+ timings.push(VisitTiming {
91
+ visit_idx,
92
+ service_start: clock,
93
+ });
94
+
95
+ let service_minutes = visit.duration_minutes.max(0);
96
+ stats.service_minutes += i64::from(service_minutes);
97
+ clock = clock.saturating_add(service_minutes);
98
+ previous_location = visit.location_idx;
99
+ }
100
+
101
+ apply_leg(
102
+ plan,
103
+ previous_location,
104
+ route.end_location_idx,
105
+ &mut clock,
106
+ &mut stats,
107
+ );
108
+
109
+ stats.finish_minute = clock;
110
+ stats.route_minutes = i64::from(clock.saturating_sub(route.shift_start_minute));
111
+ stats.overtime_minutes = i64::from((clock - route.shift_end_minute).max(0))
112
+ + (stats.route_minutes - i64::from(route.max_route_minutes)).max(0);
113
+ stats.priority_slack = priority_slack(plan, &timings);
114
+ stats
115
+ }
116
+
117
+ pub fn leg_for(
118
+ plan: &FieldServicePlan,
119
+ from_location_idx: usize,
120
+ to_location_idx: usize,
121
+ ) -> Option<&TravelLeg> {
122
+ let width = plan.locations.len();
123
+ // Travel legs are normally stored as a dense row-major matrix. The secondary
124
+ // scan keeps tests and sparse diagnostics readable without changing the
125
+ // public fact shape.
126
+ let direct_idx = from_location_idx
127
+ .checked_mul(width)?
128
+ .checked_add(to_location_idx)?;
129
+
130
+ if let Some(leg) = plan.travel_legs.get(direct_idx) {
131
+ if leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx {
132
+ return Some(leg);
133
+ }
134
+ }
135
+
136
+ plan.travel_legs.iter().find(|leg| {
137
+ leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx
138
+ })
139
+ }
140
+
141
+ fn apply_leg(
142
+ plan: &FieldServicePlan,
143
+ from_location_idx: usize,
144
+ to_location_idx: usize,
145
+ clock: &mut i32,
146
+ stats: &mut RouteStats,
147
+ ) {
148
+ let Some(leg) = leg_for(plan, from_location_idx, to_location_idx) else {
149
+ stats.unreachable_legs += 1;
150
+ return;
151
+ };
152
+
153
+ if !leg.reachable {
154
+ stats.unreachable_legs += 1;
155
+ return;
156
+ }
157
+
158
+ // Scoring uses seconds for precision but the route clock advances in whole
159
+ // minutes because visits and shifts are modeled on a minute calendar.
160
+ stats.travel_seconds += leg.duration_seconds.max(0);
161
+ stats.distance_meters += leg.distance_meters.max(0);
162
+ if leg.duration_seconds > 0 || leg.distance_meters > 0 {
163
+ stats.scored_travel_legs += 1;
164
+ }
165
+ *clock = clock.saturating_add(div_ceil(leg.duration_seconds.max(0), 60) as i32);
166
+ }
167
+
168
+ fn priority_slack(plan: &FieldServicePlan, timings: &[VisitTiming]) -> i64 {
169
+ timings
170
+ .iter()
171
+ .filter_map(|timing| {
172
+ plan.service_visits
173
+ .get(timing.visit_idx)
174
+ .map(|visit| visit_priority_slack(visit, timing.service_start))
175
+ })
176
+ .sum()
177
+ }
178
+
179
+ fn visit_priority_slack(visit: &ServiceVisit, service_start: i32) -> i64 {
180
+ let slack_quarters = i64::from((visit.latest_minute - service_start).max(0) / 15);
181
+ i64::from(visit.priority.max(1)) * (slack_quarters + 1)
182
+ }
183
+
184
+ fn mask_contains(available: i64, required: i64) -> bool {
185
+ (available & required) == required
186
+ }
187
+
188
+ fn div_ceil(value: i64, divisor: i64) -> i64 {
189
+ if value <= 0 {
190
+ 0
191
+ } else {
192
+ (value + divisor - 1) / divisor
193
+ }
194
+ }
src/domain/service_visit.rs ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use solverforge::prelude::*;
3
+
4
+ /// Customer job that must be inserted into exactly one technician route.
5
+ ///
6
+ /// This is problem data, not a planning entity. The solver does not mutate the
7
+ /// visit itself; it mutates `TechnicianRoute.visits`, which stores indexes into
8
+ /// the `FieldServicePlan.service_visits` vector.
9
+ #[problem_fact]
10
+ #[derive(Serialize, Deserialize)]
11
+ pub struct ServiceVisit {
12
+ #[planning_id]
13
+ pub id: String,
14
+ #[serde(skip, default)]
15
+ pub index: usize,
16
+ pub name: String,
17
+ pub customer: String,
18
+ pub location_idx: usize,
19
+ pub duration_minutes: i32,
20
+ pub earliest_minute: i32,
21
+ pub latest_minute: i32,
22
+ pub required_skill_mask: i64,
23
+ pub required_parts_mask: i64,
24
+ pub priority: i32,
25
+ pub territory: String,
26
+ }
27
+
28
+ /// Constructor payload for `ServiceVisit`.
29
+ ///
30
+ /// Keeping construction grouped avoids a long positional argument list where a
31
+ /// beginner could easily swap time windows, masks, or location indexes.
32
+ #[derive(Debug, Clone)]
33
+ pub struct ServiceVisitInit {
34
+ pub id: String,
35
+ pub name: String,
36
+ pub customer: String,
37
+ pub location_idx: usize,
38
+ pub duration_minutes: i32,
39
+ pub earliest_minute: i32,
40
+ pub latest_minute: i32,
41
+ pub required_skill_mask: i64,
42
+ pub required_parts_mask: i64,
43
+ pub priority: i32,
44
+ pub territory: String,
45
+ }
46
+
47
+ impl ServiceVisit {
48
+ /// Builds one immutable service-visit fact.
49
+ pub fn new(init: ServiceVisitInit) -> Self {
50
+ Self {
51
+ id: init.id,
52
+ index: 0,
53
+ name: init.name,
54
+ customer: init.customer,
55
+ location_idx: init.location_idx,
56
+ duration_minutes: init.duration_minutes,
57
+ earliest_minute: init.earliest_minute,
58
+ latest_minute: init.latest_minute,
59
+ required_skill_mask: init.required_skill_mask,
60
+ required_parts_mask: init.required_parts_mask,
61
+ priority: init.priority,
62
+ territory: init.territory,
63
+ }
64
+ }
65
+ }
66
+
67
+ #[cfg(test)]
68
+ mod tests {
69
+ use super::*;
70
+
71
+ #[test]
72
+ fn test_service_visit_construction() {
73
+ let fact = ServiceVisit::new(ServiceVisitInit {
74
+ id: "test-id".to_string(),
75
+ name: "test".to_string(),
76
+ customer: "test".to_string(),
77
+ location_idx: Default::default(),
78
+ duration_minutes: Default::default(),
79
+ earliest_minute: Default::default(),
80
+ latest_minute: Default::default(),
81
+ required_skill_mask: Default::default(),
82
+ required_parts_mask: Default::default(),
83
+ priority: Default::default(),
84
+ territory: "test".to_string(),
85
+ });
86
+ assert_eq!(fact.id, "test-id");
87
+ assert_eq!(fact.name, "test");
88
+ let _ = &fact.customer;
89
+ let _ = &fact.location_idx;
90
+ let _ = &fact.duration_minutes;
91
+ let _ = &fact.earliest_minute;
92
+ let _ = &fact.latest_minute;
93
+ let _ = &fact.required_skill_mask;
94
+ let _ = &fact.required_parts_mask;
95
+ let _ = &fact.priority;
96
+ let _ = &fact.territory;
97
+ }
98
+ }
src/domain/technician_route.rs ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use solverforge::prelude::*;
3
+
4
+ use super::route_metrics::RouteStats;
5
+
6
+ /// One technician's route, including the visit order SolverForge is allowed to change.
7
+ ///
8
+ /// A `TechnicianRoute` is the planning entity in this app. Its descriptive
9
+ /// fields are fixed input data for the technician, while `visits` is the list
10
+ /// planning variable that local search reorders and moves between routes.
11
+ #[planning_entity]
12
+ #[derive(Serialize, Deserialize)]
13
+ pub struct TechnicianRoute {
14
+ #[planning_id]
15
+ pub id: String,
16
+ pub technician_id: String,
17
+ pub technician_name: String,
18
+ pub color: String,
19
+ pub start_location_idx: usize,
20
+ pub end_location_idx: usize,
21
+ pub shift_start_minute: i32,
22
+ pub shift_end_minute: i32,
23
+ pub max_route_minutes: i32,
24
+ pub skill_mask: i64,
25
+ pub inventory_mask: i64,
26
+ pub territory: String,
27
+ // SolverForge mutates this vector. Each value is an index into
28
+ // `FieldServicePlan.service_visits`, not a copied `ServiceVisit`.
29
+ // @solverforge:begin entity-variables
30
+ #[planning_list_variable(element_collection = "service_visits")]
31
+ pub visits: Vec<usize>,
32
+ // @solverforge:end entity-variables
33
+ #[cascading_update_shadow_variable]
34
+ #[serde(skip, default)]
35
+ pub route_invalid_visits: i64,
36
+ #[cascading_update_shadow_variable]
37
+ #[serde(skip, default)]
38
+ pub route_valid_visits: i64,
39
+ #[cascading_update_shadow_variable]
40
+ #[serde(skip, default)]
41
+ pub route_scored_travel_legs: i64,
42
+ #[cascading_update_shadow_variable]
43
+ #[serde(skip, default)]
44
+ pub route_unreachable_legs: i64,
45
+ #[cascading_update_shadow_variable]
46
+ #[serde(skip, default)]
47
+ pub route_missing_skill_visits: i64,
48
+ #[cascading_update_shadow_variable]
49
+ #[serde(skip, default)]
50
+ pub route_missing_part_visits: i64,
51
+ #[cascading_update_shadow_variable]
52
+ #[serde(skip, default)]
53
+ pub route_late_visits: i64,
54
+ #[cascading_update_shadow_variable]
55
+ #[serde(skip, default)]
56
+ pub route_late_minutes: i64,
57
+ #[cascading_update_shadow_variable]
58
+ #[serde(skip, default)]
59
+ pub route_overtime_minutes: i64,
60
+ #[cascading_update_shadow_variable]
61
+ #[serde(skip, default)]
62
+ pub route_travel_seconds: i64,
63
+ #[cascading_update_shadow_variable]
64
+ #[serde(skip, default)]
65
+ pub route_distance_meters: i64,
66
+ #[cascading_update_shadow_variable]
67
+ #[serde(skip, default)]
68
+ pub route_service_minutes: i64,
69
+ #[cascading_update_shadow_variable]
70
+ #[serde(skip, default)]
71
+ pub route_waiting_minutes: i64,
72
+ #[cascading_update_shadow_variable]
73
+ #[serde(skip, default)]
74
+ pub route_minutes: i64,
75
+ #[cascading_update_shadow_variable]
76
+ #[serde(skip, default)]
77
+ pub route_finish_minute: i32,
78
+ #[cascading_update_shadow_variable]
79
+ #[serde(skip, default)]
80
+ pub route_territory_matches: i64,
81
+ #[cascading_update_shadow_variable]
82
+ #[serde(skip, default)]
83
+ pub route_priority_slack: i64,
84
+ }
85
+
86
+ /// Constructor payload for `TechnicianRoute`.
87
+ ///
88
+ /// Grouping the technician attributes keeps call sites readable and makes the
89
+ /// immutable technician data visually separate from the mutable route list.
90
+ #[derive(Debug, Clone)]
91
+ pub struct TechnicianRouteInit {
92
+ pub id: String,
93
+ pub technician_id: String,
94
+ pub technician_name: String,
95
+ pub color: String,
96
+ pub start_location_idx: usize,
97
+ pub end_location_idx: usize,
98
+ pub shift_start_minute: i32,
99
+ pub shift_end_minute: i32,
100
+ pub max_route_minutes: i32,
101
+ pub skill_mask: i64,
102
+ pub inventory_mask: i64,
103
+ pub territory: String,
104
+ }
105
+
106
+ impl TechnicianRoute {
107
+ /// Builds an empty route for one technician.
108
+ ///
109
+ /// The list variable starts empty so construction heuristics can choose the
110
+ /// first assignment instead of inheriting a hand-written visit order.
111
+ pub fn new(init: TechnicianRouteInit) -> Self {
112
+ Self {
113
+ id: init.id,
114
+ technician_id: init.technician_id,
115
+ technician_name: init.technician_name,
116
+ color: init.color,
117
+ start_location_idx: init.start_location_idx,
118
+ end_location_idx: init.end_location_idx,
119
+ shift_start_minute: init.shift_start_minute,
120
+ shift_end_minute: init.shift_end_minute,
121
+ max_route_minutes: init.max_route_minutes,
122
+ skill_mask: init.skill_mask,
123
+ inventory_mask: init.inventory_mask,
124
+ territory: init.territory,
125
+ // @solverforge:begin entity-variable-init
126
+ visits: Vec::new(),
127
+ // @solverforge:end entity-variable-init
128
+ route_invalid_visits: 0,
129
+ route_valid_visits: 0,
130
+ route_scored_travel_legs: 0,
131
+ route_unreachable_legs: 0,
132
+ route_missing_skill_visits: 0,
133
+ route_missing_part_visits: 0,
134
+ route_late_visits: 0,
135
+ route_late_minutes: 0,
136
+ route_overtime_minutes: 0,
137
+ route_travel_seconds: 0,
138
+ route_distance_meters: 0,
139
+ route_service_minutes: 0,
140
+ route_waiting_minutes: 0,
141
+ route_minutes: 0,
142
+ route_finish_minute: init.shift_start_minute,
143
+ route_territory_matches: 0,
144
+ route_priority_slack: 0,
145
+ }
146
+ }
147
+
148
+ /// Copies freshly computed route metrics into SolverForge shadow fields.
149
+ pub fn apply_route_stats(&mut self, stats: RouteStats) {
150
+ self.route_invalid_visits = stats.invalid_visits;
151
+ self.route_valid_visits = stats.valid_visits;
152
+ self.route_scored_travel_legs = stats.scored_travel_legs;
153
+ self.route_unreachable_legs = stats.unreachable_legs;
154
+ self.route_missing_skill_visits = stats.missing_skill_visits;
155
+ self.route_missing_part_visits = stats.missing_part_visits;
156
+ self.route_late_visits = stats.late_visits;
157
+ self.route_late_minutes = stats.late_minutes;
158
+ self.route_overtime_minutes = stats.overtime_minutes;
159
+ self.route_travel_seconds = stats.travel_seconds;
160
+ self.route_distance_meters = stats.distance_meters;
161
+ self.route_service_minutes = stats.service_minutes;
162
+ self.route_waiting_minutes = stats.waiting_minutes;
163
+ self.route_minutes = stats.route_minutes;
164
+ self.route_finish_minute = stats.finish_minute;
165
+ self.route_territory_matches = stats.territory_matches;
166
+ self.route_priority_slack = stats.priority_slack;
167
+ }
168
+
169
+ pub fn travel_penalty(&self) -> i64 {
170
+ div_ceil(self.route_travel_seconds, 60) + div_ceil(self.route_distance_meters, 1_000)
171
+ }
172
+
173
+ pub fn workload_penalty(&self) -> i64 {
174
+ let normalized = (self.route_minutes / 15).max(0);
175
+ normalized * normalized
176
+ }
177
+ }
178
+
179
+ fn div_ceil(value: i64, divisor: i64) -> i64 {
180
+ if value <= 0 {
181
+ 0
182
+ } else {
183
+ (value + divisor - 1) / divisor
184
+ }
185
+ }
186
+
187
+ #[cfg(test)]
188
+ mod tests {
189
+ use super::*;
190
+
191
+ #[test]
192
+ fn test_technician_route_construction() {
193
+ let entity = TechnicianRoute::new(TechnicianRouteInit {
194
+ id: "test-id".to_string(),
195
+ technician_id: "test".to_string(),
196
+ technician_name: "test".to_string(),
197
+ color: "test".to_string(),
198
+ start_location_idx: Default::default(),
199
+ end_location_idx: Default::default(),
200
+ shift_start_minute: Default::default(),
201
+ shift_end_minute: Default::default(),
202
+ max_route_minutes: Default::default(),
203
+ skill_mask: Default::default(),
204
+ inventory_mask: Default::default(),
205
+ territory: "test".to_string(),
206
+ });
207
+ assert_eq!(entity.id, "test-id");
208
+ let _ = &entity.technician_id;
209
+ let _ = &entity.technician_name;
210
+ let _ = &entity.color;
211
+ let _ = &entity.start_location_idx;
212
+ let _ = &entity.end_location_idx;
213
+ let _ = &entity.shift_start_minute;
214
+ let _ = &entity.shift_end_minute;
215
+ let _ = &entity.max_route_minutes;
216
+ let _ = &entity.skill_mask;
217
+ let _ = &entity.inventory_mask;
218
+ let _ = &entity.territory;
219
+ }
220
+ }
src/domain/travel_leg.rs ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use solverforge::prelude::*;
3
+
4
+ /// Precomputed travel fact between two locations.
5
+ ///
6
+ /// Constraints read these facts while scoring a route. Keeping travel as problem
7
+ /// data makes scoring deterministic: the solver evaluates candidate visit
8
+ /// orders against the matrix already attached to the plan instead of calling the
9
+ /// map service during every move.
10
+ #[problem_fact]
11
+ #[derive(Serialize, Deserialize)]
12
+ pub struct TravelLeg {
13
+ #[planning_id]
14
+ pub id: String,
15
+ pub name: String,
16
+ pub from_location_idx: usize,
17
+ pub to_location_idx: usize,
18
+ pub duration_seconds: i64,
19
+ pub distance_meters: i64,
20
+ pub reachable: bool,
21
+ }
22
+
23
+ /// Constructor payload for `TravelLeg`.
24
+ ///
25
+ /// The route matrix has many similar numeric fields, so named initialization is
26
+ /// easier to audit than positional arguments.
27
+ #[derive(Debug, Clone)]
28
+ pub struct TravelLegInit {
29
+ pub id: String,
30
+ pub name: String,
31
+ pub from_location_idx: usize,
32
+ pub to_location_idx: usize,
33
+ pub duration_seconds: i64,
34
+ pub distance_meters: i64,
35
+ pub reachable: bool,
36
+ }
37
+
38
+ impl TravelLeg {
39
+ /// Builds one directed matrix entry from `from_location_idx` to `to_location_idx`.
40
+ pub fn new(init: TravelLegInit) -> Self {
41
+ Self {
42
+ id: init.id,
43
+ name: init.name,
44
+ from_location_idx: init.from_location_idx,
45
+ to_location_idx: init.to_location_idx,
46
+ duration_seconds: init.duration_seconds,
47
+ distance_meters: init.distance_meters,
48
+ reachable: init.reachable,
49
+ }
50
+ }
51
+ }
52
+
53
+ #[cfg(test)]
54
+ mod tests {
55
+ use super::*;
56
+
57
+ #[test]
58
+ fn test_travel_leg_construction() {
59
+ let fact = TravelLeg::new(TravelLegInit {
60
+ id: "test-id".to_string(),
61
+ name: "test".to_string(),
62
+ from_location_idx: Default::default(),
63
+ to_location_idx: Default::default(),
64
+ duration_seconds: Default::default(),
65
+ distance_meters: Default::default(),
66
+ reachable: false,
67
+ });
68
+ assert_eq!(fact.id, "test-id");
69
+ assert_eq!(fact.name, "test");
70
+ let _ = &fact.from_location_idx;
71
+ let _ = &fact.to_location_idx;
72
+ let _ = &fact.duration_seconds;
73
+ let _ = &fact.distance_meters;
74
+ let _ = &fact.reachable;
75
+ }
76
+ }
src/lib.rs ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! SolverForge field-service routing application.
2
+ //!
3
+ //! The crate follows the same teaching shape as the other use cases: `domain`
4
+ //! defines the planning model, `constraints` defines scoring, `data` builds the
5
+ //! deterministic Bergamo instance, `solver` owns retained runtime jobs, and
6
+ //! `api` exposes the browser-facing HTTP surface.
7
+
8
+ pub mod api;
9
+ pub mod constraints;
10
+ pub mod data;
11
+ pub mod domain;
12
+ pub mod solver;
src/main.rs ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Axum entrypoint for the field-service routing app.
2
+ //!
3
+ //! The binary serves stock SolverForge UI assets, this app's static files, and
4
+ //! the retained-job API from one process so the Docker Space only needs one
5
+ //! `PORT` binding.
6
+
7
+ use solverforge_fsr::api;
8
+
9
+ use std::net::SocketAddr;
10
+ use std::sync::Arc;
11
+ use tower_http::cors::{Any, CorsLayer};
12
+ use tower_http::services::ServeDir;
13
+
14
+ #[tokio::main]
15
+ async fn main() {
16
+ // Use the stock SolverForge console logger so solve progress appears in
17
+ // local runs and Space container logs.
18
+ solverforge::console::init();
19
+
20
+ let state = Arc::new(api::AppState::new());
21
+
22
+ let cors = CorsLayer::new()
23
+ .allow_origin(Any)
24
+ .allow_methods(Any)
25
+ .allow_headers(Any);
26
+
27
+ let app = api::router(state)
28
+ .merge(solverforge_ui::routes())
29
+ .fallback_service(ServeDir::new("static"))
30
+ .layer(cors);
31
+
32
+ // Hugging Face Spaces inject `PORT`; 7860 remains the local default used in
33
+ // docs, tests, and the Makefile.
34
+ let port = std::env::var("PORT")
35
+ .ok()
36
+ .and_then(|value| value.parse::<u16>().ok())
37
+ .unwrap_or(7860);
38
+ let addr = SocketAddr::from(([0, 0, 0, 0], port));
39
+ println!("▸ solverforge-fsr listening on http://{}", addr);
40
+ println!("▸ Open http://localhost:{} in your browser\n", port);
41
+
42
+ let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
43
+ axum::serve(listener, app)
44
+ .with_graceful_shutdown(shutdown_signal())
45
+ .await
46
+ .unwrap();
47
+ }
48
+
49
+ async fn shutdown_signal() {
50
+ let ctrl_c = async {
51
+ tokio::signal::ctrl_c()
52
+ .await
53
+ .expect("failed to install Ctrl-C handler");
54
+ };
55
+
56
+ #[cfg(unix)]
57
+ let terminate = async {
58
+ tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
59
+ .expect("failed to install SIGTERM handler")
60
+ .recv()
61
+ .await;
62
+ };
63
+
64
+ #[cfg(not(unix))]
65
+ let terminate = std::future::pending::<()>();
66
+
67
+ tokio::select! {
68
+ _ = ctrl_c => {},
69
+ _ = terminate => {},
70
+ }
71
+
72
+ println!("▸ solverforge-fsr shutting down");
73
+ }
src/solver/event_payload.rs ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! JSON event payloads sent over the FSR SSE stream.
2
+ //!
3
+ //! SolverForge emits strongly typed lifecycle events. This module converts them
4
+ //! to the stable camelCase JSON shape consumed by the browser status bar and
5
+ //! route renderer.
6
+
7
+ use serde::Serialize;
8
+ use std::time::Duration;
9
+
10
+ use solverforge::{
11
+ HardSoftScore, SolverEventMetadata, SolverLifecycleState, SolverSnapshot, SolverStatus,
12
+ SolverTelemetry, SolverTerminalReason,
13
+ };
14
+
15
+ use crate::api::PlanDto;
16
+ use crate::domain::FieldServicePlan;
17
+
18
+ #[derive(Serialize)]
19
+ #[serde(rename_all = "camelCase")]
20
+ struct TelemetryPayload {
21
+ elapsed_ms: u64,
22
+ step_count: u64,
23
+ moves_generated: u64,
24
+ moves_evaluated: u64,
25
+ moves_accepted: u64,
26
+ score_calculations: u64,
27
+ generation_ms: u64,
28
+ evaluation_ms: u64,
29
+ moves_per_second: u64,
30
+ acceptance_rate: f64,
31
+ }
32
+
33
+ #[derive(Serialize)]
34
+ #[serde(rename_all = "camelCase")]
35
+ struct JobEventPayload {
36
+ id: String,
37
+ job_id: String,
38
+ event_type: &'static str,
39
+ event_sequence: u64,
40
+ lifecycle_state: &'static str,
41
+ terminal_reason: Option<&'static str>,
42
+ telemetry: TelemetryPayload,
43
+ current_score: Option<String>,
44
+ best_score: Option<String>,
45
+ snapshot_revision: Option<u64>,
46
+ solution: Option<PlanDto>,
47
+ error: Option<String>,
48
+ }
49
+
50
+ pub(super) fn status_event_payload(
51
+ job_id: usize,
52
+ event_type: &'static str,
53
+ status: &SolverStatus<HardSoftScore>,
54
+ ) -> String {
55
+ serialize_payload(JobEventPayload {
56
+ id: job_id.to_string(),
57
+ job_id: job_id.to_string(),
58
+ event_type,
59
+ event_sequence: status.event_sequence,
60
+ lifecycle_state: lifecycle_state_label(status.lifecycle_state),
61
+ terminal_reason: status.terminal_reason.map(terminal_reason_label),
62
+ telemetry: telemetry_payload(&status.telemetry),
63
+ current_score: status.current_score.map(|score| score.to_string()),
64
+ best_score: status.best_score.map(|score| score.to_string()),
65
+ snapshot_revision: status.latest_snapshot_revision,
66
+ solution: None,
67
+ error: None,
68
+ })
69
+ }
70
+
71
+ pub(super) fn snapshot_status_event_payload(
72
+ job_id: usize,
73
+ event_type: &'static str,
74
+ status: &SolverStatus<HardSoftScore>,
75
+ snapshot: &SolverSnapshot<FieldServicePlan>,
76
+ ) -> String {
77
+ serialize_payload(JobEventPayload {
78
+ id: job_id.to_string(),
79
+ job_id: job_id.to_string(),
80
+ event_type,
81
+ event_sequence: status.event_sequence,
82
+ lifecycle_state: lifecycle_state_label(status.lifecycle_state),
83
+ terminal_reason: status.terminal_reason.map(terminal_reason_label),
84
+ telemetry: telemetry_payload(&status.telemetry),
85
+ current_score: status
86
+ .current_score
87
+ .or(snapshot.current_score)
88
+ .map(|score| score.to_string()),
89
+ best_score: status
90
+ .best_score
91
+ .or(snapshot.best_score)
92
+ .map(|score| score.to_string()),
93
+ snapshot_revision: Some(snapshot.snapshot_revision),
94
+ solution: Some(PlanDto::from_plan(&snapshot.solution)),
95
+ error: None,
96
+ })
97
+ }
98
+
99
+ pub(super) fn event_payload(
100
+ job_id: usize,
101
+ event_type: &'static str,
102
+ metadata: &SolverEventMetadata<HardSoftScore>,
103
+ solution: Option<&FieldServicePlan>,
104
+ error: Option<&str>,
105
+ ) -> String {
106
+ serialize_payload(JobEventPayload {
107
+ id: job_id.to_string(),
108
+ job_id: job_id.to_string(),
109
+ event_type,
110
+ event_sequence: metadata.event_sequence,
111
+ lifecycle_state: lifecycle_state_label(metadata.lifecycle_state),
112
+ terminal_reason: metadata.terminal_reason.map(terminal_reason_label),
113
+ telemetry: telemetry_payload(&metadata.telemetry),
114
+ current_score: metadata.current_score.map(|score| score.to_string()),
115
+ best_score: metadata.best_score.map(|score| score.to_string()),
116
+ snapshot_revision: metadata.snapshot_revision,
117
+ solution: solution.map(PlanDto::from_plan),
118
+ error: error.map(ToOwned::to_owned),
119
+ })
120
+ }
121
+
122
+ pub(super) fn bootstrap_event_type(state: SolverLifecycleState) -> &'static str {
123
+ match state {
124
+ SolverLifecycleState::Solving => "progress",
125
+ SolverLifecycleState::PauseRequested => "pause_requested",
126
+ SolverLifecycleState::Paused => "paused",
127
+ SolverLifecycleState::Completed => "completed",
128
+ SolverLifecycleState::Cancelled => "cancelled",
129
+ SolverLifecycleState::Failed => "failed",
130
+ }
131
+ }
132
+
133
+ pub(super) fn bootstrap_snapshot_event_type(state: SolverLifecycleState) -> &'static str {
134
+ match state {
135
+ SolverLifecycleState::Solving => "best_solution",
136
+ other => bootstrap_event_type(other),
137
+ }
138
+ }
139
+
140
+ fn serialize_payload(payload: JobEventPayload) -> String {
141
+ serde_json::to_string(&payload).expect("failed to serialize solver lifecycle payload")
142
+ }
143
+
144
+ fn telemetry_payload(telemetry: &SolverTelemetry) -> TelemetryPayload {
145
+ TelemetryPayload {
146
+ elapsed_ms: duration_to_millis(telemetry.elapsed),
147
+ step_count: telemetry.step_count,
148
+ moves_generated: telemetry.moves_generated,
149
+ moves_evaluated: telemetry.moves_evaluated,
150
+ moves_accepted: telemetry.moves_accepted,
151
+ score_calculations: telemetry.score_calculations,
152
+ generation_ms: duration_to_millis(telemetry.generation_time),
153
+ evaluation_ms: duration_to_millis(telemetry.evaluation_time),
154
+ moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed),
155
+ acceptance_rate: derive_acceptance_rate(
156
+ telemetry.moves_accepted,
157
+ telemetry.moves_evaluated,
158
+ ),
159
+ }
160
+ }
161
+
162
+ fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str {
163
+ match state {
164
+ SolverLifecycleState::Solving => "SOLVING",
165
+ SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED",
166
+ SolverLifecycleState::Paused => "PAUSED",
167
+ SolverLifecycleState::Completed => "COMPLETED",
168
+ SolverLifecycleState::Cancelled => "CANCELLED",
169
+ SolverLifecycleState::Failed => "FAILED",
170
+ }
171
+ }
172
+
173
+ fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str {
174
+ match reason {
175
+ SolverTerminalReason::Completed => "completed",
176
+ SolverTerminalReason::TerminatedByConfig => "terminated_by_config",
177
+ SolverTerminalReason::Cancelled => "cancelled",
178
+ SolverTerminalReason::Failed => "failed",
179
+ }
180
+ }
181
+
182
+ fn duration_to_millis(duration: Duration) -> u64 {
183
+ duration.as_millis().min(u128::from(u64::MAX)) as u64
184
+ }
185
+
186
+ fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 {
187
+ let nanos = elapsed.as_nanos();
188
+ if nanos == 0 {
189
+ 0
190
+ } else {
191
+ let per_second = u128::from(count)
192
+ .saturating_mul(1_000_000_000)
193
+ .checked_div(nanos)
194
+ .unwrap_or(0);
195
+ per_second.min(u128::from(u64::MAX)) as u64
196
+ }
197
+ }
198
+
199
+ fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 {
200
+ if moves_evaluated == 0 {
201
+ 0.0
202
+ } else {
203
+ moves_accepted as f64 / moves_evaluated as f64
204
+ }
205
+ }
src/solver/mod.rs ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Solver-runtime facade exports for the FSR app.
2
+ //!
3
+ //! Keeping the retained runtime behind `SolverService` prevents HTTP handlers
4
+ //! from depending directly on `SolverManager<FieldServicePlan>`.
5
+
6
+ mod event_payload;
7
+ mod service;
8
+
9
+ pub use service::SolverService;
10
+ pub use solverforge::SolverStatus;