github-actions[bot] commited on
Commit
2574e86
·
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 +86 -0
  6. CHANGELOG.md +19 -0
  7. Cargo.lock +2672 -0
  8. Cargo.toml +30 -0
  9. Dockerfile +36 -0
  10. Makefile +207 -0
  11. README.md +208 -0
  12. WIREFRAME.md +192 -0
  13. docs/screenshot.png +3 -0
  14. solver.toml +48 -0
  15. solverforge.app.toml +100 -0
  16. src/api/dto.rs +255 -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 +191 -0
  23. src/constraints/balance_workload.rs +17 -0
  24. src/constraints/minimize_travel.rs +17 -0
  25. src/constraints/mod.rs +50 -0
  26. src/constraints/priority_slack.rs +17 -0
  27. src/constraints/reachable_legs.rs +15 -0
  28. src/constraints/required_parts.rs +17 -0
  29. src/constraints/required_skills.rs +17 -0
  30. src/constraints/route_constraint.rs +104 -0
  31. src/constraints/route_metrics.rs +284 -0
  32. src/constraints/route_metrics_tests.rs +154 -0
  33. src/constraints/shift_capacity.rs +17 -0
  34. src/constraints/territory_affinity.rs +17 -0
  35. src/constraints/time_windows.rs +17 -0
  36. src/data/bergamo_catalog.rs +60 -0
  37. src/data/bergamo_locations.rs +194 -0
  38. src/data/bergamo_profiles.rs +61 -0
  39. src/data/bergamo_technicians.rs +70 -0
  40. src/data/data_seed.rs +292 -0
  41. src/data/mod.rs +15 -0
  42. src/domain/field_service_plan.rs +67 -0
  43. src/domain/location.rs +73 -0
  44. src/domain/mod.rs +26 -0
  45. src/domain/service_visit.rs +95 -0
  46. src/domain/technician_route.rs +112 -0
  47. src/domain/travel_leg.rs +76 -0
  48. src/lib.rs +12 -0
  49. src/main.rs +73 -0
  50. src/solver/event_payload.rs +205 -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,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 `1.0.1`,
7
+ 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
+ - `src/domain/location.rs`, `service_visit.rs`, and `travel_leg.rs` own the
12
+ problem facts.
13
+ - `src/domain/technician_route.rs` owns the planning entity and its `visits`
14
+ list variable.
15
+ - `src/constraints/` owns one score rule per file plus route metric helpers.
16
+ - `src/data/data_seed.rs` owns deterministic Bergamo demo generation and road
17
+ matrix preparation.
18
+ - `src/api/` owns REST, DTO, route geometry, and SSE surfaces.
19
+ - `src/solver/` owns retained-job runtime orchestration.
20
+ - `static/` owns the browser workspace, split by responsibility
21
+ (`app-route-state.js`, `app-render-routes.js`, etc.).
22
+ - `Dockerfile`, `Makefile`, `solver.toml`, and `solverforge.app.toml` define
23
+ the deployment and runtime contract.
24
+
25
+ Keep handwritten source, docs, and deployment files under 300 lines; split by
26
+ module or responsibility when a file approaches that size.
27
+
28
+ ## Build, Test, and Development Commands
29
+
30
+ - `make doctor` checks local `cargo`, `rustc`, `node`, and `docker` readiness.
31
+ - `make run` runs the debug server on `PORT` (default `7860`).
32
+ - `make build-release` builds `solverforge_fsr` in release mode.
33
+ - `make test` runs Rust tests plus frontend JavaScript syntax checks.
34
+ - `make lint` runs `cargo fmt --check`, clippy with warnings denied, and JS syntax checks.
35
+ - `make ci-local` runs the full Hugging Face Space validation path, including Docker image build.
36
+ - `make space-run` builds and runs the Docker Space image locally.
37
+
38
+ ## Coding Style & Naming Conventions
39
+
40
+ Use idiomatic Rust 2021 with `cargo fmt` formatting and clippy under
41
+ `-D warnings`. Rust modules and files use `snake_case`; types use `PascalCase`;
42
+ functions, fields, and variables use `snake_case`. Keep API DTOs explicit and
43
+ snapshot-scoped. Frontend files should stay plain JavaScript modules with clear
44
+ ownership boundaries rather than large shared scripts.
45
+
46
+ ## Testing Guidelines
47
+
48
+ Place Rust unit tests near the code they cover, using descriptive names such as
49
+ `reports_unreachable_route_segments`. Run `make test` before handing off normal
50
+ changes and `make ci-local` before deployment, dependency, Docker, or Space
51
+ changes. Frontend validation is currently syntax-level via `node --check` over
52
+ `static/*.js`.
53
+
54
+ ## Documentation And Commenting Policy
55
+
56
+ Assume a reader who is new to Rust and new to planning optimization.
57
+
58
+ - Keep `README.md`, `WIREFRAME.md`, this file, `solver.toml`,
59
+ `solverforge.app.toml`, `static/sf-config.json`, and the visible browser API
60
+ guide aligned.
61
+ - Keep `docs/screenshot.png` current whenever the visible browser shell changes.
62
+ - Add module or function comments where code coordinates SolverForge concepts:
63
+ facts, planning entities, variables, retained jobs, road matrices, route
64
+ geometry, or score math.
65
+ - Explain domain meaning and solver consequences. Do not keep scaffold
66
+ placeholders, future-tense planning prose, or comments that merely restate
67
+ syntax.
68
+ - When docs mention versions, counts, routes, demo IDs, solver policy, or
69
+ validation expectations, verify those facts against current code in the same
70
+ patch.
71
+
72
+ ## Commit & Pull Request Guidelines
73
+
74
+ History uses conventional commits such as `feat(fsr): ...`, `fix(ui): ...`,
75
+ and `chore: ...`. Keep each commit focused on one revertable
76
+ intent and include a full body when the change spans behavior, deployment, or
77
+ dependencies. PRs should describe the user-visible effect, linked issue or
78
+ review comment, validation commands run, and include screenshots for visible UI
79
+ changes.
80
+
81
+ ## Security & Configuration Tips
82
+
83
+ Do not commit credentials, local Hugging Face tokens, generated desktop bundles,
84
+ or build output. Keep Docker/Space builds registry-backed through the declared
85
+ crates.io dependency line unless the build context explicitly vendors local
86
+ crates.
CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this use case are documented in this file.
4
+
5
+ ## 2.0.0 (2026-05-14)
6
+
7
+ ### Maintenance
8
+
9
+ * **release:** set the public app release line to 2.0.0 across Cargo metadata and release validation.
10
+
11
+ ## 1.0.1 (2026-05-14)
12
+
13
+ ### Features
14
+
15
+ * **fsr:** publish the SolverForge field-service routing use case in the bundle.
16
+
17
+ ### Maintenance
18
+
19
+ * **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,2672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.16.3"
35
+ source = "registry+https://github.com/rust-lang/crates.io-index"
36
+ checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
37
+ dependencies = [
38
+ "aws-lc-sys",
39
+ "zeroize",
40
+ ]
41
+
42
+ [[package]]
43
+ name = "aws-lc-sys"
44
+ version = "0.40.0"
45
+ source = "registry+https://github.com/rust-lang/crates.io-index"
46
+ checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
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.11.1"
115
+ source = "registry+https://github.com/rust-lang/crates.io-index"
116
+ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
117
+
118
+ [[package]]
119
+ name = "bumpalo"
120
+ version = "3.20.2"
121
+ source = "registry+https://github.com/rust-lang/crates.io-index"
122
+ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
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.62"
133
+ source = "registry+https://github.com/rust-lang/crates.io-index"
134
+ checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
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.5"
247
+ source = "registry+https://github.com/rust-lang/crates.io-index"
248
+ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
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.15.0"
264
+ source = "registry+https://github.com/rust-lang/crates.io-index"
265
+ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
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.0"
448
+ source = "registry+https://github.com/rust-lang/crates.io-index"
449
+ checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
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.9.0"
499
+ source = "registry+https://github.com/rust-lang/crates.io-index"
500
+ checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
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.98"
772
+ source = "registry+https://github.com/rust-lang/crates.io-index"
773
+ checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
774
+ dependencies = [
775
+ "cfg-if",
776
+ "futures-util",
777
+ "once_cell",
778
+ "wasm-bindgen",
779
+ ]
780
+
781
+ [[package]]
782
+ name = "lazy_static"
783
+ version = "1.5.0"
784
+ source = "registry+https://github.com/rust-lang/crates.io-index"
785
+ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
786
+
787
+ [[package]]
788
+ name = "leb128fmt"
789
+ version = "0.1.0"
790
+ source = "registry+https://github.com/rust-lang/crates.io-index"
791
+ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
792
+
793
+ [[package]]
794
+ name = "libc"
795
+ version = "0.2.186"
796
+ source = "registry+https://github.com/rust-lang/crates.io-index"
797
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
798
+
799
+ [[package]]
800
+ name = "litemap"
801
+ version = "0.8.2"
802
+ source = "registry+https://github.com/rust-lang/crates.io-index"
803
+ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
804
+
805
+ [[package]]
806
+ name = "lock_api"
807
+ version = "0.4.14"
808
+ source = "registry+https://github.com/rust-lang/crates.io-index"
809
+ checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
810
+ dependencies = [
811
+ "scopeguard",
812
+ ]
813
+
814
+ [[package]]
815
+ name = "log"
816
+ version = "0.4.29"
817
+ source = "registry+https://github.com/rust-lang/crates.io-index"
818
+ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
819
+
820
+ [[package]]
821
+ name = "lru-slab"
822
+ version = "0.1.2"
823
+ source = "registry+https://github.com/rust-lang/crates.io-index"
824
+ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
825
+
826
+ [[package]]
827
+ name = "matchers"
828
+ version = "0.2.0"
829
+ source = "registry+https://github.com/rust-lang/crates.io-index"
830
+ checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
831
+ dependencies = [
832
+ "regex-automata",
833
+ ]
834
+
835
+ [[package]]
836
+ name = "matchit"
837
+ version = "0.8.4"
838
+ source = "registry+https://github.com/rust-lang/crates.io-index"
839
+ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
840
+
841
+ [[package]]
842
+ name = "memchr"
843
+ version = "2.8.0"
844
+ source = "registry+https://github.com/rust-lang/crates.io-index"
845
+ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
846
+
847
+ [[package]]
848
+ name = "mime"
849
+ version = "0.3.17"
850
+ source = "registry+https://github.com/rust-lang/crates.io-index"
851
+ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
852
+
853
+ [[package]]
854
+ name = "mime_guess"
855
+ version = "2.0.5"
856
+ source = "registry+https://github.com/rust-lang/crates.io-index"
857
+ checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
858
+ dependencies = [
859
+ "mime",
860
+ "unicase",
861
+ ]
862
+
863
+ [[package]]
864
+ name = "mio"
865
+ version = "1.2.0"
866
+ source = "registry+https://github.com/rust-lang/crates.io-index"
867
+ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
868
+ dependencies = [
869
+ "libc",
870
+ "wasi",
871
+ "windows-sys 0.61.2",
872
+ ]
873
+
874
+ [[package]]
875
+ name = "nu-ansi-term"
876
+ version = "0.50.3"
877
+ source = "registry+https://github.com/rust-lang/crates.io-index"
878
+ checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
879
+ dependencies = [
880
+ "windows-sys 0.61.2",
881
+ ]
882
+
883
+ [[package]]
884
+ name = "num-format"
885
+ version = "0.4.4"
886
+ source = "registry+https://github.com/rust-lang/crates.io-index"
887
+ checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
888
+ dependencies = [
889
+ "arrayvec",
890
+ "itoa",
891
+ ]
892
+
893
+ [[package]]
894
+ name = "once_cell"
895
+ version = "1.21.4"
896
+ source = "registry+https://github.com/rust-lang/crates.io-index"
897
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
898
+
899
+ [[package]]
900
+ name = "openssl-probe"
901
+ version = "0.2.1"
902
+ source = "registry+https://github.com/rust-lang/crates.io-index"
903
+ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
904
+
905
+ [[package]]
906
+ name = "owo-colors"
907
+ version = "4.3.0"
908
+ source = "registry+https://github.com/rust-lang/crates.io-index"
909
+ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
910
+
911
+ [[package]]
912
+ name = "parking_lot"
913
+ version = "0.12.5"
914
+ source = "registry+https://github.com/rust-lang/crates.io-index"
915
+ checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
916
+ dependencies = [
917
+ "lock_api",
918
+ "parking_lot_core",
919
+ ]
920
+
921
+ [[package]]
922
+ name = "parking_lot_core"
923
+ version = "0.9.12"
924
+ source = "registry+https://github.com/rust-lang/crates.io-index"
925
+ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
926
+ dependencies = [
927
+ "cfg-if",
928
+ "libc",
929
+ "redox_syscall",
930
+ "smallvec",
931
+ "windows-link",
932
+ ]
933
+
934
+ [[package]]
935
+ name = "percent-encoding"
936
+ version = "2.3.2"
937
+ source = "registry+https://github.com/rust-lang/crates.io-index"
938
+ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
939
+
940
+ [[package]]
941
+ name = "pin-project-lite"
942
+ version = "0.2.17"
943
+ source = "registry+https://github.com/rust-lang/crates.io-index"
944
+ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
945
+
946
+ [[package]]
947
+ name = "potential_utf"
948
+ version = "0.1.5"
949
+ source = "registry+https://github.com/rust-lang/crates.io-index"
950
+ checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
951
+ dependencies = [
952
+ "zerovec",
953
+ ]
954
+
955
+ [[package]]
956
+ name = "ppv-lite86"
957
+ version = "0.2.21"
958
+ source = "registry+https://github.com/rust-lang/crates.io-index"
959
+ checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
960
+ dependencies = [
961
+ "zerocopy",
962
+ ]
963
+
964
+ [[package]]
965
+ name = "prettyplease"
966
+ version = "0.2.37"
967
+ source = "registry+https://github.com/rust-lang/crates.io-index"
968
+ checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
969
+ dependencies = [
970
+ "proc-macro2",
971
+ "syn",
972
+ ]
973
+
974
+ [[package]]
975
+ name = "proc-macro2"
976
+ version = "1.0.106"
977
+ source = "registry+https://github.com/rust-lang/crates.io-index"
978
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
979
+ dependencies = [
980
+ "unicode-ident",
981
+ ]
982
+
983
+ [[package]]
984
+ name = "quinn"
985
+ version = "0.11.9"
986
+ source = "registry+https://github.com/rust-lang/crates.io-index"
987
+ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
988
+ dependencies = [
989
+ "bytes",
990
+ "cfg_aliases",
991
+ "pin-project-lite",
992
+ "quinn-proto",
993
+ "quinn-udp",
994
+ "rustc-hash",
995
+ "rustls",
996
+ "socket2",
997
+ "thiserror",
998
+ "tokio",
999
+ "tracing",
1000
+ "web-time",
1001
+ ]
1002
+
1003
+ [[package]]
1004
+ name = "quinn-proto"
1005
+ version = "0.11.14"
1006
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1007
+ checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
1008
+ dependencies = [
1009
+ "aws-lc-rs",
1010
+ "bytes",
1011
+ "getrandom 0.3.4",
1012
+ "lru-slab",
1013
+ "rand 0.9.4",
1014
+ "ring",
1015
+ "rustc-hash",
1016
+ "rustls",
1017
+ "rustls-pki-types",
1018
+ "slab",
1019
+ "thiserror",
1020
+ "tinyvec",
1021
+ "tracing",
1022
+ "web-time",
1023
+ ]
1024
+
1025
+ [[package]]
1026
+ name = "quinn-udp"
1027
+ version = "0.5.14"
1028
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1029
+ checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
1030
+ dependencies = [
1031
+ "cfg_aliases",
1032
+ "libc",
1033
+ "once_cell",
1034
+ "socket2",
1035
+ "tracing",
1036
+ "windows-sys 0.60.2",
1037
+ ]
1038
+
1039
+ [[package]]
1040
+ name = "quote"
1041
+ version = "1.0.45"
1042
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1043
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
1044
+ dependencies = [
1045
+ "proc-macro2",
1046
+ ]
1047
+
1048
+ [[package]]
1049
+ name = "r-efi"
1050
+ version = "5.3.0"
1051
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1052
+ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1053
+
1054
+ [[package]]
1055
+ name = "r-efi"
1056
+ version = "6.0.0"
1057
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1058
+ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1059
+
1060
+ [[package]]
1061
+ name = "rand"
1062
+ version = "0.9.4"
1063
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1064
+ checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
1065
+ dependencies = [
1066
+ "rand_chacha 0.9.0",
1067
+ "rand_core 0.9.5",
1068
+ ]
1069
+
1070
+ [[package]]
1071
+ name = "rand"
1072
+ version = "0.10.1"
1073
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1074
+ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
1075
+ dependencies = [
1076
+ "chacha20",
1077
+ "getrandom 0.4.2",
1078
+ "rand_core 0.10.1",
1079
+ ]
1080
+
1081
+ [[package]]
1082
+ name = "rand_chacha"
1083
+ version = "0.9.0"
1084
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1085
+ checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1086
+ dependencies = [
1087
+ "ppv-lite86",
1088
+ "rand_core 0.9.5",
1089
+ ]
1090
+
1091
+ [[package]]
1092
+ name = "rand_chacha"
1093
+ version = "0.10.0"
1094
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1095
+ checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
1096
+ dependencies = [
1097
+ "ppv-lite86",
1098
+ "rand_core 0.10.1",
1099
+ ]
1100
+
1101
+ [[package]]
1102
+ name = "rand_core"
1103
+ version = "0.9.5"
1104
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1105
+ checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1106
+ dependencies = [
1107
+ "getrandom 0.3.4",
1108
+ ]
1109
+
1110
+ [[package]]
1111
+ name = "rand_core"
1112
+ version = "0.10.1"
1113
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1114
+ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1115
+
1116
+ [[package]]
1117
+ name = "rayon"
1118
+ version = "1.12.0"
1119
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1120
+ checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
1121
+ dependencies = [
1122
+ "either",
1123
+ "rayon-core",
1124
+ ]
1125
+
1126
+ [[package]]
1127
+ name = "rayon-core"
1128
+ version = "1.13.0"
1129
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1130
+ checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
1131
+ dependencies = [
1132
+ "crossbeam-deque",
1133
+ "crossbeam-utils",
1134
+ ]
1135
+
1136
+ [[package]]
1137
+ name = "redox_syscall"
1138
+ version = "0.5.18"
1139
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1140
+ checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1141
+ dependencies = [
1142
+ "bitflags",
1143
+ ]
1144
+
1145
+ [[package]]
1146
+ name = "regex-automata"
1147
+ version = "0.4.14"
1148
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1149
+ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1150
+ dependencies = [
1151
+ "aho-corasick",
1152
+ "memchr",
1153
+ "regex-syntax",
1154
+ ]
1155
+
1156
+ [[package]]
1157
+ name = "regex-syntax"
1158
+ version = "0.8.10"
1159
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1160
+ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
1161
+
1162
+ [[package]]
1163
+ name = "reqwest"
1164
+ version = "0.13.3"
1165
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1166
+ checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
1167
+ dependencies = [
1168
+ "base64",
1169
+ "bytes",
1170
+ "encoding_rs",
1171
+ "futures-core",
1172
+ "h2",
1173
+ "http",
1174
+ "http-body",
1175
+ "http-body-util",
1176
+ "hyper",
1177
+ "hyper-rustls",
1178
+ "hyper-util",
1179
+ "js-sys",
1180
+ "log",
1181
+ "mime",
1182
+ "percent-encoding",
1183
+ "pin-project-lite",
1184
+ "quinn",
1185
+ "rustls",
1186
+ "rustls-pki-types",
1187
+ "rustls-platform-verifier",
1188
+ "serde",
1189
+ "serde_json",
1190
+ "sync_wrapper",
1191
+ "tokio",
1192
+ "tokio-rustls",
1193
+ "tower",
1194
+ "tower-http",
1195
+ "tower-service",
1196
+ "url",
1197
+ "wasm-bindgen",
1198
+ "wasm-bindgen-futures",
1199
+ "web-sys",
1200
+ ]
1201
+
1202
+ [[package]]
1203
+ name = "ring"
1204
+ version = "0.17.14"
1205
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1206
+ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1207
+ dependencies = [
1208
+ "cc",
1209
+ "cfg-if",
1210
+ "getrandom 0.2.17",
1211
+ "libc",
1212
+ "untrusted",
1213
+ "windows-sys 0.52.0",
1214
+ ]
1215
+
1216
+ [[package]]
1217
+ name = "rustc-hash"
1218
+ version = "2.1.2"
1219
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1220
+ checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
1221
+
1222
+ [[package]]
1223
+ name = "rustc_version"
1224
+ version = "0.4.1"
1225
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1226
+ checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1227
+ dependencies = [
1228
+ "semver",
1229
+ ]
1230
+
1231
+ [[package]]
1232
+ name = "rustls"
1233
+ version = "0.23.40"
1234
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1235
+ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
1236
+ dependencies = [
1237
+ "aws-lc-rs",
1238
+ "once_cell",
1239
+ "rustls-pki-types",
1240
+ "rustls-webpki",
1241
+ "subtle",
1242
+ "zeroize",
1243
+ ]
1244
+
1245
+ [[package]]
1246
+ name = "rustls-native-certs"
1247
+ version = "0.8.3"
1248
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1249
+ checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
1250
+ dependencies = [
1251
+ "openssl-probe",
1252
+ "rustls-pki-types",
1253
+ "schannel",
1254
+ "security-framework",
1255
+ ]
1256
+
1257
+ [[package]]
1258
+ name = "rustls-pki-types"
1259
+ version = "1.14.1"
1260
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1261
+ checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
1262
+ dependencies = [
1263
+ "web-time",
1264
+ "zeroize",
1265
+ ]
1266
+
1267
+ [[package]]
1268
+ name = "rustls-platform-verifier"
1269
+ version = "0.7.0"
1270
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1271
+ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
1272
+ dependencies = [
1273
+ "core-foundation 0.10.1",
1274
+ "core-foundation-sys",
1275
+ "jni",
1276
+ "log",
1277
+ "once_cell",
1278
+ "rustls",
1279
+ "rustls-native-certs",
1280
+ "rustls-platform-verifier-android",
1281
+ "rustls-webpki",
1282
+ "security-framework",
1283
+ "security-framework-sys",
1284
+ "webpki-root-certs",
1285
+ "windows-sys 0.61.2",
1286
+ ]
1287
+
1288
+ [[package]]
1289
+ name = "rustls-platform-verifier-android"
1290
+ version = "0.1.1"
1291
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1292
+ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
1293
+
1294
+ [[package]]
1295
+ name = "rustls-webpki"
1296
+ version = "0.103.13"
1297
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1298
+ checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
1299
+ dependencies = [
1300
+ "aws-lc-rs",
1301
+ "ring",
1302
+ "rustls-pki-types",
1303
+ "untrusted",
1304
+ ]
1305
+
1306
+ [[package]]
1307
+ name = "rustversion"
1308
+ version = "1.0.22"
1309
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1310
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1311
+
1312
+ [[package]]
1313
+ name = "ryu"
1314
+ version = "1.0.23"
1315
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1316
+ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1317
+
1318
+ [[package]]
1319
+ name = "same-file"
1320
+ version = "1.0.6"
1321
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1322
+ checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1323
+ dependencies = [
1324
+ "winapi-util",
1325
+ ]
1326
+
1327
+ [[package]]
1328
+ name = "schannel"
1329
+ version = "0.1.29"
1330
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1331
+ checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
1332
+ dependencies = [
1333
+ "windows-sys 0.61.2",
1334
+ ]
1335
+
1336
+ [[package]]
1337
+ name = "scopeguard"
1338
+ version = "1.2.0"
1339
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1340
+ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1341
+
1342
+ [[package]]
1343
+ name = "security-framework"
1344
+ version = "3.7.0"
1345
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1346
+ checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1347
+ dependencies = [
1348
+ "bitflags",
1349
+ "core-foundation 0.10.1",
1350
+ "core-foundation-sys",
1351
+ "libc",
1352
+ "security-framework-sys",
1353
+ ]
1354
+
1355
+ [[package]]
1356
+ name = "security-framework-sys"
1357
+ version = "2.17.0"
1358
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1359
+ checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
1360
+ dependencies = [
1361
+ "core-foundation-sys",
1362
+ "libc",
1363
+ ]
1364
+
1365
+ [[package]]
1366
+ name = "semver"
1367
+ version = "1.0.28"
1368
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1369
+ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1370
+
1371
+ [[package]]
1372
+ name = "serde"
1373
+ version = "1.0.228"
1374
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1375
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1376
+ dependencies = [
1377
+ "serde_core",
1378
+ "serde_derive",
1379
+ ]
1380
+
1381
+ [[package]]
1382
+ name = "serde_core"
1383
+ version = "1.0.228"
1384
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1385
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1386
+ dependencies = [
1387
+ "serde_derive",
1388
+ ]
1389
+
1390
+ [[package]]
1391
+ name = "serde_derive"
1392
+ version = "1.0.228"
1393
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1394
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1395
+ dependencies = [
1396
+ "proc-macro2",
1397
+ "quote",
1398
+ "syn",
1399
+ ]
1400
+
1401
+ [[package]]
1402
+ name = "serde_json"
1403
+ version = "1.0.149"
1404
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1405
+ checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
1406
+ dependencies = [
1407
+ "itoa",
1408
+ "memchr",
1409
+ "serde",
1410
+ "serde_core",
1411
+ "zmij",
1412
+ ]
1413
+
1414
+ [[package]]
1415
+ name = "serde_path_to_error"
1416
+ version = "0.1.20"
1417
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1418
+ checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
1419
+ dependencies = [
1420
+ "itoa",
1421
+ "serde",
1422
+ "serde_core",
1423
+ ]
1424
+
1425
+ [[package]]
1426
+ name = "serde_spanned"
1427
+ version = "1.1.1"
1428
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1429
+ checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
1430
+ dependencies = [
1431
+ "serde_core",
1432
+ ]
1433
+
1434
+ [[package]]
1435
+ name = "serde_urlencoded"
1436
+ version = "0.7.1"
1437
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1438
+ checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1439
+ dependencies = [
1440
+ "form_urlencoded",
1441
+ "itoa",
1442
+ "ryu",
1443
+ "serde",
1444
+ ]
1445
+
1446
+ [[package]]
1447
+ name = "serde_yaml"
1448
+ version = "0.9.34+deprecated"
1449
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1450
+ checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
1451
+ dependencies = [
1452
+ "indexmap",
1453
+ "itoa",
1454
+ "ryu",
1455
+ "serde",
1456
+ "unsafe-libyaml",
1457
+ ]
1458
+
1459
+ [[package]]
1460
+ name = "sharded-slab"
1461
+ version = "0.1.7"
1462
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1463
+ checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1464
+ dependencies = [
1465
+ "lazy_static",
1466
+ ]
1467
+
1468
+ [[package]]
1469
+ name = "shlex"
1470
+ version = "1.3.0"
1471
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1472
+ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1473
+
1474
+ [[package]]
1475
+ name = "signal-hook-registry"
1476
+ version = "1.4.8"
1477
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1478
+ checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1479
+ dependencies = [
1480
+ "errno",
1481
+ "libc",
1482
+ ]
1483
+
1484
+ [[package]]
1485
+ name = "simd_cesu8"
1486
+ version = "1.1.1"
1487
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1488
+ checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
1489
+ dependencies = [
1490
+ "rustc_version",
1491
+ "simdutf8",
1492
+ ]
1493
+
1494
+ [[package]]
1495
+ name = "simdutf8"
1496
+ version = "0.1.5"
1497
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1498
+ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
1499
+
1500
+ [[package]]
1501
+ name = "slab"
1502
+ version = "0.4.12"
1503
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1504
+ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1505
+
1506
+ [[package]]
1507
+ name = "smallvec"
1508
+ version = "1.15.1"
1509
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1510
+ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1511
+
1512
+ [[package]]
1513
+ name = "socket2"
1514
+ version = "0.6.3"
1515
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1516
+ checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
1517
+ dependencies = [
1518
+ "libc",
1519
+ "windows-sys 0.61.2",
1520
+ ]
1521
+
1522
+ [[package]]
1523
+ name = "solverforge"
1524
+ version = "0.13.1"
1525
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1526
+ checksum = "d86fef307d129ed9d674f29e2f37be912a9b901a4fe9f43348f1044879ef7094"
1527
+ dependencies = [
1528
+ "solverforge-config",
1529
+ "solverforge-console",
1530
+ "solverforge-core",
1531
+ "solverforge-cvrp",
1532
+ "solverforge-macros",
1533
+ "solverforge-scoring",
1534
+ "solverforge-solver",
1535
+ "tokio",
1536
+ ]
1537
+
1538
+ [[package]]
1539
+ name = "solverforge-config"
1540
+ version = "0.13.1"
1541
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1542
+ checksum = "434a6c8f9f308d20c7d364324de9d0dbb27d5fe03a7e8f1cead0c2574ebe947e"
1543
+ dependencies = [
1544
+ "serde",
1545
+ "serde_yaml",
1546
+ "solverforge-core",
1547
+ "thiserror",
1548
+ "toml",
1549
+ ]
1550
+
1551
+ [[package]]
1552
+ name = "solverforge-console"
1553
+ version = "0.13.1"
1554
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1555
+ checksum = "7456459eb2efbb3d57a6b5234c7d29a2aab84c8d14d5a09807ab0ac94bf9a786"
1556
+ dependencies = [
1557
+ "num-format",
1558
+ "owo-colors",
1559
+ "tracing",
1560
+ "tracing-subscriber",
1561
+ ]
1562
+
1563
+ [[package]]
1564
+ name = "solverforge-core"
1565
+ version = "0.13.1"
1566
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1567
+ checksum = "ccc29944f60ac425f53b4605ea2f0fa5fa499549139df7a889aa5fdc47bcc066"
1568
+ dependencies = [
1569
+ "serde",
1570
+ "thiserror",
1571
+ ]
1572
+
1573
+ [[package]]
1574
+ name = "solverforge-cvrp"
1575
+ version = "0.13.1"
1576
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1577
+ checksum = "91cbe85229dd46e845f95f1f4d2ac993145680b02e044acdce220ee4ddecbd36"
1578
+ dependencies = [
1579
+ "solverforge-solver",
1580
+ ]
1581
+
1582
+ [[package]]
1583
+ name = "solverforge-fsr"
1584
+ version = "2.0.0"
1585
+ dependencies = [
1586
+ "axum",
1587
+ "parking_lot",
1588
+ "serde",
1589
+ "serde_json",
1590
+ "solverforge",
1591
+ "solverforge-core",
1592
+ "solverforge-maps",
1593
+ "solverforge-ui",
1594
+ "tokio",
1595
+ "tokio-stream",
1596
+ "tower",
1597
+ "tower-http",
1598
+ "uuid",
1599
+ ]
1600
+
1601
+ [[package]]
1602
+ name = "solverforge-macros"
1603
+ version = "0.13.1"
1604
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1605
+ checksum = "2f830c19a7d9c7fe10911cf597b0b98c6d63ef2ec70330ac6e0b2746e8db8d98"
1606
+ dependencies = [
1607
+ "proc-macro2",
1608
+ "quote",
1609
+ "syn",
1610
+ ]
1611
+
1612
+ [[package]]
1613
+ name = "solverforge-maps"
1614
+ version = "2.1.4"
1615
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1616
+ checksum = "e31f816d221238ba3ade93315e6a605486b53d9ec26527477d165b507ece6a88"
1617
+ dependencies = [
1618
+ "rayon",
1619
+ "reqwest",
1620
+ "serde",
1621
+ "serde_json",
1622
+ "tokio",
1623
+ "tracing",
1624
+ "utoipa",
1625
+ ]
1626
+
1627
+ [[package]]
1628
+ name = "solverforge-scoring"
1629
+ version = "0.13.1"
1630
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1631
+ checksum = "1efaede461a62baa20a0f65887bda5760158a125a831ecc73c50d713ac9d6ce7"
1632
+ dependencies = [
1633
+ "solverforge-core",
1634
+ "thiserror",
1635
+ ]
1636
+
1637
+ [[package]]
1638
+ name = "solverforge-solver"
1639
+ version = "0.13.1"
1640
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1641
+ checksum = "0156969a9e5d965d7fda36a8581646115e5594a2d50d1036e101dcb001167530"
1642
+ dependencies = [
1643
+ "rand 0.10.1",
1644
+ "rand_chacha 0.10.0",
1645
+ "rayon",
1646
+ "serde",
1647
+ "smallvec",
1648
+ "solverforge-config",
1649
+ "solverforge-core",
1650
+ "solverforge-scoring",
1651
+ "thiserror",
1652
+ "tokio",
1653
+ "tracing",
1654
+ ]
1655
+
1656
+ [[package]]
1657
+ name = "solverforge-ui"
1658
+ version = "0.6.5"
1659
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1660
+ checksum = "1c7fa2d78c84af9a1e264adcffc1bdf8cb4edab8d73a3543fb448d166c95596f"
1661
+ dependencies = [
1662
+ "axum",
1663
+ "include_dir",
1664
+ ]
1665
+
1666
+ [[package]]
1667
+ name = "stable_deref_trait"
1668
+ version = "1.2.1"
1669
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1670
+ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1671
+
1672
+ [[package]]
1673
+ name = "subtle"
1674
+ version = "2.6.1"
1675
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1676
+ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1677
+
1678
+ [[package]]
1679
+ name = "syn"
1680
+ version = "2.0.117"
1681
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1682
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
1683
+ dependencies = [
1684
+ "proc-macro2",
1685
+ "quote",
1686
+ "unicode-ident",
1687
+ ]
1688
+
1689
+ [[package]]
1690
+ name = "sync_wrapper"
1691
+ version = "1.0.2"
1692
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1693
+ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1694
+ dependencies = [
1695
+ "futures-core",
1696
+ ]
1697
+
1698
+ [[package]]
1699
+ name = "synstructure"
1700
+ version = "0.13.2"
1701
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1702
+ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1703
+ dependencies = [
1704
+ "proc-macro2",
1705
+ "quote",
1706
+ "syn",
1707
+ ]
1708
+
1709
+ [[package]]
1710
+ name = "system-configuration"
1711
+ version = "0.7.0"
1712
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1713
+ checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
1714
+ dependencies = [
1715
+ "bitflags",
1716
+ "core-foundation 0.9.4",
1717
+ "system-configuration-sys",
1718
+ ]
1719
+
1720
+ [[package]]
1721
+ name = "system-configuration-sys"
1722
+ version = "0.6.0"
1723
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1724
+ checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
1725
+ dependencies = [
1726
+ "core-foundation-sys",
1727
+ "libc",
1728
+ ]
1729
+
1730
+ [[package]]
1731
+ name = "thiserror"
1732
+ version = "2.0.18"
1733
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1734
+ checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1735
+ dependencies = [
1736
+ "thiserror-impl",
1737
+ ]
1738
+
1739
+ [[package]]
1740
+ name = "thiserror-impl"
1741
+ version = "2.0.18"
1742
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1743
+ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1744
+ dependencies = [
1745
+ "proc-macro2",
1746
+ "quote",
1747
+ "syn",
1748
+ ]
1749
+
1750
+ [[package]]
1751
+ name = "thread_local"
1752
+ version = "1.1.9"
1753
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1754
+ checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
1755
+ dependencies = [
1756
+ "cfg-if",
1757
+ ]
1758
+
1759
+ [[package]]
1760
+ name = "tinystr"
1761
+ version = "0.8.3"
1762
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1763
+ checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
1764
+ dependencies = [
1765
+ "displaydoc",
1766
+ "zerovec",
1767
+ ]
1768
+
1769
+ [[package]]
1770
+ name = "tinyvec"
1771
+ version = "1.11.0"
1772
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1773
+ checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
1774
+ dependencies = [
1775
+ "tinyvec_macros",
1776
+ ]
1777
+
1778
+ [[package]]
1779
+ name = "tinyvec_macros"
1780
+ version = "0.1.1"
1781
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1782
+ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1783
+
1784
+ [[package]]
1785
+ name = "tokio"
1786
+ version = "1.52.3"
1787
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1788
+ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
1789
+ dependencies = [
1790
+ "bytes",
1791
+ "libc",
1792
+ "mio",
1793
+ "parking_lot",
1794
+ "pin-project-lite",
1795
+ "signal-hook-registry",
1796
+ "socket2",
1797
+ "tokio-macros",
1798
+ "windows-sys 0.61.2",
1799
+ ]
1800
+
1801
+ [[package]]
1802
+ name = "tokio-macros"
1803
+ version = "2.7.0"
1804
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1805
+ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
1806
+ dependencies = [
1807
+ "proc-macro2",
1808
+ "quote",
1809
+ "syn",
1810
+ ]
1811
+
1812
+ [[package]]
1813
+ name = "tokio-rustls"
1814
+ version = "0.26.4"
1815
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1816
+ checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
1817
+ dependencies = [
1818
+ "rustls",
1819
+ "tokio",
1820
+ ]
1821
+
1822
+ [[package]]
1823
+ name = "tokio-stream"
1824
+ version = "0.1.18"
1825
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1826
+ checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
1827
+ dependencies = [
1828
+ "futures-core",
1829
+ "pin-project-lite",
1830
+ "tokio",
1831
+ "tokio-util",
1832
+ ]
1833
+
1834
+ [[package]]
1835
+ name = "tokio-util"
1836
+ version = "0.7.18"
1837
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1838
+ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
1839
+ dependencies = [
1840
+ "bytes",
1841
+ "futures-core",
1842
+ "futures-sink",
1843
+ "pin-project-lite",
1844
+ "tokio",
1845
+ ]
1846
+
1847
+ [[package]]
1848
+ name = "toml"
1849
+ version = "1.1.2+spec-1.1.0"
1850
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1851
+ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
1852
+ dependencies = [
1853
+ "indexmap",
1854
+ "serde_core",
1855
+ "serde_spanned",
1856
+ "toml_datetime",
1857
+ "toml_parser",
1858
+ "toml_writer",
1859
+ "winnow",
1860
+ ]
1861
+
1862
+ [[package]]
1863
+ name = "toml_datetime"
1864
+ version = "1.1.1+spec-1.1.0"
1865
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1866
+ checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
1867
+ dependencies = [
1868
+ "serde_core",
1869
+ ]
1870
+
1871
+ [[package]]
1872
+ name = "toml_parser"
1873
+ version = "1.1.2+spec-1.1.0"
1874
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1875
+ checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
1876
+ dependencies = [
1877
+ "winnow",
1878
+ ]
1879
+
1880
+ [[package]]
1881
+ name = "toml_writer"
1882
+ version = "1.1.1+spec-1.1.0"
1883
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1884
+ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
1885
+
1886
+ [[package]]
1887
+ name = "tower"
1888
+ version = "0.5.3"
1889
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1890
+ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
1891
+ dependencies = [
1892
+ "futures-core",
1893
+ "futures-util",
1894
+ "pin-project-lite",
1895
+ "sync_wrapper",
1896
+ "tokio",
1897
+ "tower-layer",
1898
+ "tower-service",
1899
+ "tracing",
1900
+ ]
1901
+
1902
+ [[package]]
1903
+ name = "tower-http"
1904
+ version = "0.6.10"
1905
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1906
+ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
1907
+ dependencies = [
1908
+ "bitflags",
1909
+ "bytes",
1910
+ "futures-core",
1911
+ "futures-util",
1912
+ "http",
1913
+ "http-body",
1914
+ "http-body-util",
1915
+ "http-range-header",
1916
+ "httpdate",
1917
+ "mime",
1918
+ "mime_guess",
1919
+ "percent-encoding",
1920
+ "pin-project-lite",
1921
+ "tokio",
1922
+ "tokio-util",
1923
+ "tower",
1924
+ "tower-layer",
1925
+ "tower-service",
1926
+ "url",
1927
+ ]
1928
+
1929
+ [[package]]
1930
+ name = "tower-layer"
1931
+ version = "0.3.3"
1932
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1933
+ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
1934
+
1935
+ [[package]]
1936
+ name = "tower-service"
1937
+ version = "0.3.3"
1938
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1939
+ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
1940
+
1941
+ [[package]]
1942
+ name = "tracing"
1943
+ version = "0.1.44"
1944
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1945
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1946
+ dependencies = [
1947
+ "log",
1948
+ "pin-project-lite",
1949
+ "tracing-attributes",
1950
+ "tracing-core",
1951
+ ]
1952
+
1953
+ [[package]]
1954
+ name = "tracing-attributes"
1955
+ version = "0.1.31"
1956
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1957
+ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1958
+ dependencies = [
1959
+ "proc-macro2",
1960
+ "quote",
1961
+ "syn",
1962
+ ]
1963
+
1964
+ [[package]]
1965
+ name = "tracing-core"
1966
+ version = "0.1.36"
1967
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1968
+ checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1969
+ dependencies = [
1970
+ "once_cell",
1971
+ "valuable",
1972
+ ]
1973
+
1974
+ [[package]]
1975
+ name = "tracing-log"
1976
+ version = "0.2.0"
1977
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1978
+ checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1979
+ dependencies = [
1980
+ "log",
1981
+ "once_cell",
1982
+ "tracing-core",
1983
+ ]
1984
+
1985
+ [[package]]
1986
+ name = "tracing-subscriber"
1987
+ version = "0.3.23"
1988
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1989
+ checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
1990
+ dependencies = [
1991
+ "matchers",
1992
+ "nu-ansi-term",
1993
+ "once_cell",
1994
+ "regex-automata",
1995
+ "sharded-slab",
1996
+ "smallvec",
1997
+ "thread_local",
1998
+ "tracing",
1999
+ "tracing-core",
2000
+ "tracing-log",
2001
+ ]
2002
+
2003
+ [[package]]
2004
+ name = "try-lock"
2005
+ version = "0.2.5"
2006
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2007
+ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2008
+
2009
+ [[package]]
2010
+ name = "unicase"
2011
+ version = "2.9.0"
2012
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2013
+ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
2014
+
2015
+ [[package]]
2016
+ name = "unicode-ident"
2017
+ version = "1.0.24"
2018
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2019
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2020
+
2021
+ [[package]]
2022
+ name = "unicode-xid"
2023
+ version = "0.2.6"
2024
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2025
+ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2026
+
2027
+ [[package]]
2028
+ name = "unsafe-libyaml"
2029
+ version = "0.2.11"
2030
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2031
+ checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
2032
+
2033
+ [[package]]
2034
+ name = "untrusted"
2035
+ version = "0.9.0"
2036
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2037
+ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2038
+
2039
+ [[package]]
2040
+ name = "url"
2041
+ version = "2.5.8"
2042
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2043
+ checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2044
+ dependencies = [
2045
+ "form_urlencoded",
2046
+ "idna",
2047
+ "percent-encoding",
2048
+ "serde",
2049
+ ]
2050
+
2051
+ [[package]]
2052
+ name = "utf8_iter"
2053
+ version = "1.0.4"
2054
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2055
+ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2056
+
2057
+ [[package]]
2058
+ name = "utoipa"
2059
+ version = "5.5.0"
2060
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2061
+ checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
2062
+ dependencies = [
2063
+ "indexmap",
2064
+ "serde",
2065
+ "serde_json",
2066
+ "utoipa-gen",
2067
+ ]
2068
+
2069
+ [[package]]
2070
+ name = "utoipa-gen"
2071
+ version = "5.5.0"
2072
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2073
+ checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
2074
+ dependencies = [
2075
+ "proc-macro2",
2076
+ "quote",
2077
+ "syn",
2078
+ ]
2079
+
2080
+ [[package]]
2081
+ name = "uuid"
2082
+ version = "1.23.1"
2083
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2084
+ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
2085
+ dependencies = [
2086
+ "getrandom 0.4.2",
2087
+ "js-sys",
2088
+ "serde_core",
2089
+ "wasm-bindgen",
2090
+ ]
2091
+
2092
+ [[package]]
2093
+ name = "valuable"
2094
+ version = "0.1.1"
2095
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2096
+ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
2097
+
2098
+ [[package]]
2099
+ name = "walkdir"
2100
+ version = "2.5.0"
2101
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2102
+ checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2103
+ dependencies = [
2104
+ "same-file",
2105
+ "winapi-util",
2106
+ ]
2107
+
2108
+ [[package]]
2109
+ name = "want"
2110
+ version = "0.3.1"
2111
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2112
+ checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
2113
+ dependencies = [
2114
+ "try-lock",
2115
+ ]
2116
+
2117
+ [[package]]
2118
+ name = "wasi"
2119
+ version = "0.11.1+wasi-snapshot-preview1"
2120
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2121
+ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2122
+
2123
+ [[package]]
2124
+ name = "wasip2"
2125
+ version = "1.0.3+wasi-0.2.9"
2126
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2127
+ checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
2128
+ dependencies = [
2129
+ "wit-bindgen 0.57.1",
2130
+ ]
2131
+
2132
+ [[package]]
2133
+ name = "wasip3"
2134
+ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
2135
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2136
+ checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
2137
+ dependencies = [
2138
+ "wit-bindgen 0.51.0",
2139
+ ]
2140
+
2141
+ [[package]]
2142
+ name = "wasm-bindgen"
2143
+ version = "0.2.121"
2144
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2145
+ checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
2146
+ dependencies = [
2147
+ "cfg-if",
2148
+ "once_cell",
2149
+ "rustversion",
2150
+ "wasm-bindgen-macro",
2151
+ "wasm-bindgen-shared",
2152
+ ]
2153
+
2154
+ [[package]]
2155
+ name = "wasm-bindgen-futures"
2156
+ version = "0.4.71"
2157
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2158
+ checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
2159
+ dependencies = [
2160
+ "js-sys",
2161
+ "wasm-bindgen",
2162
+ ]
2163
+
2164
+ [[package]]
2165
+ name = "wasm-bindgen-macro"
2166
+ version = "0.2.121"
2167
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2168
+ checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
2169
+ dependencies = [
2170
+ "quote",
2171
+ "wasm-bindgen-macro-support",
2172
+ ]
2173
+
2174
+ [[package]]
2175
+ name = "wasm-bindgen-macro-support"
2176
+ version = "0.2.121"
2177
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2178
+ checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
2179
+ dependencies = [
2180
+ "bumpalo",
2181
+ "proc-macro2",
2182
+ "quote",
2183
+ "syn",
2184
+ "wasm-bindgen-shared",
2185
+ ]
2186
+
2187
+ [[package]]
2188
+ name = "wasm-bindgen-shared"
2189
+ version = "0.2.121"
2190
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2191
+ checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
2192
+ dependencies = [
2193
+ "unicode-ident",
2194
+ ]
2195
+
2196
+ [[package]]
2197
+ name = "wasm-encoder"
2198
+ version = "0.244.0"
2199
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2200
+ checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
2201
+ dependencies = [
2202
+ "leb128fmt",
2203
+ "wasmparser",
2204
+ ]
2205
+
2206
+ [[package]]
2207
+ name = "wasm-metadata"
2208
+ version = "0.244.0"
2209
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2210
+ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
2211
+ dependencies = [
2212
+ "anyhow",
2213
+ "indexmap",
2214
+ "wasm-encoder",
2215
+ "wasmparser",
2216
+ ]
2217
+
2218
+ [[package]]
2219
+ name = "wasmparser"
2220
+ version = "0.244.0"
2221
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2222
+ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
2223
+ dependencies = [
2224
+ "bitflags",
2225
+ "hashbrown 0.15.5",
2226
+ "indexmap",
2227
+ "semver",
2228
+ ]
2229
+
2230
+ [[package]]
2231
+ name = "web-sys"
2232
+ version = "0.3.98"
2233
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2234
+ checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
2235
+ dependencies = [
2236
+ "js-sys",
2237
+ "wasm-bindgen",
2238
+ ]
2239
+
2240
+ [[package]]
2241
+ name = "web-time"
2242
+ version = "1.1.0"
2243
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2244
+ checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2245
+ dependencies = [
2246
+ "js-sys",
2247
+ "wasm-bindgen",
2248
+ ]
2249
+
2250
+ [[package]]
2251
+ name = "webpki-root-certs"
2252
+ version = "1.0.7"
2253
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2254
+ checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
2255
+ dependencies = [
2256
+ "rustls-pki-types",
2257
+ ]
2258
+
2259
+ [[package]]
2260
+ name = "winapi-util"
2261
+ version = "0.1.11"
2262
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2263
+ checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2264
+ dependencies = [
2265
+ "windows-sys 0.61.2",
2266
+ ]
2267
+
2268
+ [[package]]
2269
+ name = "windows-link"
2270
+ version = "0.2.1"
2271
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2272
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2273
+
2274
+ [[package]]
2275
+ name = "windows-registry"
2276
+ version = "0.6.1"
2277
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2278
+ checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
2279
+ dependencies = [
2280
+ "windows-link",
2281
+ "windows-result",
2282
+ "windows-strings",
2283
+ ]
2284
+
2285
+ [[package]]
2286
+ name = "windows-result"
2287
+ version = "0.4.1"
2288
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2289
+ checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2290
+ dependencies = [
2291
+ "windows-link",
2292
+ ]
2293
+
2294
+ [[package]]
2295
+ name = "windows-strings"
2296
+ version = "0.5.1"
2297
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2298
+ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2299
+ dependencies = [
2300
+ "windows-link",
2301
+ ]
2302
+
2303
+ [[package]]
2304
+ name = "windows-sys"
2305
+ version = "0.52.0"
2306
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2307
+ checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2308
+ dependencies = [
2309
+ "windows-targets 0.52.6",
2310
+ ]
2311
+
2312
+ [[package]]
2313
+ name = "windows-sys"
2314
+ version = "0.60.2"
2315
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2316
+ checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
2317
+ dependencies = [
2318
+ "windows-targets 0.53.5",
2319
+ ]
2320
+
2321
+ [[package]]
2322
+ name = "windows-sys"
2323
+ version = "0.61.2"
2324
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2325
+ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2326
+ dependencies = [
2327
+ "windows-link",
2328
+ ]
2329
+
2330
+ [[package]]
2331
+ name = "windows-targets"
2332
+ version = "0.52.6"
2333
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2334
+ checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2335
+ dependencies = [
2336
+ "windows_aarch64_gnullvm 0.52.6",
2337
+ "windows_aarch64_msvc 0.52.6",
2338
+ "windows_i686_gnu 0.52.6",
2339
+ "windows_i686_gnullvm 0.52.6",
2340
+ "windows_i686_msvc 0.52.6",
2341
+ "windows_x86_64_gnu 0.52.6",
2342
+ "windows_x86_64_gnullvm 0.52.6",
2343
+ "windows_x86_64_msvc 0.52.6",
2344
+ ]
2345
+
2346
+ [[package]]
2347
+ name = "windows-targets"
2348
+ version = "0.53.5"
2349
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2350
+ checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
2351
+ dependencies = [
2352
+ "windows-link",
2353
+ "windows_aarch64_gnullvm 0.53.1",
2354
+ "windows_aarch64_msvc 0.53.1",
2355
+ "windows_i686_gnu 0.53.1",
2356
+ "windows_i686_gnullvm 0.53.1",
2357
+ "windows_i686_msvc 0.53.1",
2358
+ "windows_x86_64_gnu 0.53.1",
2359
+ "windows_x86_64_gnullvm 0.53.1",
2360
+ "windows_x86_64_msvc 0.53.1",
2361
+ ]
2362
+
2363
+ [[package]]
2364
+ name = "windows_aarch64_gnullvm"
2365
+ version = "0.52.6"
2366
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2367
+ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2368
+
2369
+ [[package]]
2370
+ name = "windows_aarch64_gnullvm"
2371
+ version = "0.53.1"
2372
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2373
+ checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
2374
+
2375
+ [[package]]
2376
+ name = "windows_aarch64_msvc"
2377
+ version = "0.52.6"
2378
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2379
+ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2380
+
2381
+ [[package]]
2382
+ name = "windows_aarch64_msvc"
2383
+ version = "0.53.1"
2384
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2385
+ checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
2386
+
2387
+ [[package]]
2388
+ name = "windows_i686_gnu"
2389
+ version = "0.52.6"
2390
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2391
+ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2392
+
2393
+ [[package]]
2394
+ name = "windows_i686_gnu"
2395
+ version = "0.53.1"
2396
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2397
+ checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
2398
+
2399
+ [[package]]
2400
+ name = "windows_i686_gnullvm"
2401
+ version = "0.52.6"
2402
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2403
+ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2404
+
2405
+ [[package]]
2406
+ name = "windows_i686_gnullvm"
2407
+ version = "0.53.1"
2408
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2409
+ checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
2410
+
2411
+ [[package]]
2412
+ name = "windows_i686_msvc"
2413
+ version = "0.52.6"
2414
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2415
+ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2416
+
2417
+ [[package]]
2418
+ name = "windows_i686_msvc"
2419
+ version = "0.53.1"
2420
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2421
+ checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
2422
+
2423
+ [[package]]
2424
+ name = "windows_x86_64_gnu"
2425
+ version = "0.52.6"
2426
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2427
+ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2428
+
2429
+ [[package]]
2430
+ name = "windows_x86_64_gnu"
2431
+ version = "0.53.1"
2432
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2433
+ checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
2434
+
2435
+ [[package]]
2436
+ name = "windows_x86_64_gnullvm"
2437
+ version = "0.52.6"
2438
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2439
+ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2440
+
2441
+ [[package]]
2442
+ name = "windows_x86_64_gnullvm"
2443
+ version = "0.53.1"
2444
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2445
+ checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
2446
+
2447
+ [[package]]
2448
+ name = "windows_x86_64_msvc"
2449
+ version = "0.52.6"
2450
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2451
+ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2452
+
2453
+ [[package]]
2454
+ name = "windows_x86_64_msvc"
2455
+ version = "0.53.1"
2456
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2457
+ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
2458
+
2459
+ [[package]]
2460
+ name = "winnow"
2461
+ version = "1.0.2"
2462
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2463
+ checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
2464
+
2465
+ [[package]]
2466
+ name = "wit-bindgen"
2467
+ version = "0.51.0"
2468
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2469
+ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
2470
+ dependencies = [
2471
+ "wit-bindgen-rust-macro",
2472
+ ]
2473
+
2474
+ [[package]]
2475
+ name = "wit-bindgen"
2476
+ version = "0.57.1"
2477
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2478
+ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
2479
+
2480
+ [[package]]
2481
+ name = "wit-bindgen-core"
2482
+ version = "0.51.0"
2483
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2484
+ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
2485
+ dependencies = [
2486
+ "anyhow",
2487
+ "heck",
2488
+ "wit-parser",
2489
+ ]
2490
+
2491
+ [[package]]
2492
+ name = "wit-bindgen-rust"
2493
+ version = "0.51.0"
2494
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2495
+ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
2496
+ dependencies = [
2497
+ "anyhow",
2498
+ "heck",
2499
+ "indexmap",
2500
+ "prettyplease",
2501
+ "syn",
2502
+ "wasm-metadata",
2503
+ "wit-bindgen-core",
2504
+ "wit-component",
2505
+ ]
2506
+
2507
+ [[package]]
2508
+ name = "wit-bindgen-rust-macro"
2509
+ version = "0.51.0"
2510
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2511
+ checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
2512
+ dependencies = [
2513
+ "anyhow",
2514
+ "prettyplease",
2515
+ "proc-macro2",
2516
+ "quote",
2517
+ "syn",
2518
+ "wit-bindgen-core",
2519
+ "wit-bindgen-rust",
2520
+ ]
2521
+
2522
+ [[package]]
2523
+ name = "wit-component"
2524
+ version = "0.244.0"
2525
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2526
+ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
2527
+ dependencies = [
2528
+ "anyhow",
2529
+ "bitflags",
2530
+ "indexmap",
2531
+ "log",
2532
+ "serde",
2533
+ "serde_derive",
2534
+ "serde_json",
2535
+ "wasm-encoder",
2536
+ "wasm-metadata",
2537
+ "wasmparser",
2538
+ "wit-parser",
2539
+ ]
2540
+
2541
+ [[package]]
2542
+ name = "wit-parser"
2543
+ version = "0.244.0"
2544
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2545
+ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
2546
+ dependencies = [
2547
+ "anyhow",
2548
+ "id-arena",
2549
+ "indexmap",
2550
+ "log",
2551
+ "semver",
2552
+ "serde",
2553
+ "serde_derive",
2554
+ "serde_json",
2555
+ "unicode-xid",
2556
+ "wasmparser",
2557
+ ]
2558
+
2559
+ [[package]]
2560
+ name = "writeable"
2561
+ version = "0.6.3"
2562
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2563
+ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
2564
+
2565
+ [[package]]
2566
+ name = "yoke"
2567
+ version = "0.8.2"
2568
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2569
+ checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
2570
+ dependencies = [
2571
+ "stable_deref_trait",
2572
+ "yoke-derive",
2573
+ "zerofrom",
2574
+ ]
2575
+
2576
+ [[package]]
2577
+ name = "yoke-derive"
2578
+ version = "0.8.2"
2579
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2580
+ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
2581
+ dependencies = [
2582
+ "proc-macro2",
2583
+ "quote",
2584
+ "syn",
2585
+ "synstructure",
2586
+ ]
2587
+
2588
+ [[package]]
2589
+ name = "zerocopy"
2590
+ version = "0.8.48"
2591
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2592
+ checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
2593
+ dependencies = [
2594
+ "zerocopy-derive",
2595
+ ]
2596
+
2597
+ [[package]]
2598
+ name = "zerocopy-derive"
2599
+ version = "0.8.48"
2600
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2601
+ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
2602
+ dependencies = [
2603
+ "proc-macro2",
2604
+ "quote",
2605
+ "syn",
2606
+ ]
2607
+
2608
+ [[package]]
2609
+ name = "zerofrom"
2610
+ version = "0.1.8"
2611
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2612
+ checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
2613
+ dependencies = [
2614
+ "zerofrom-derive",
2615
+ ]
2616
+
2617
+ [[package]]
2618
+ name = "zerofrom-derive"
2619
+ version = "0.1.7"
2620
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2621
+ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
2622
+ dependencies = [
2623
+ "proc-macro2",
2624
+ "quote",
2625
+ "syn",
2626
+ "synstructure",
2627
+ ]
2628
+
2629
+ [[package]]
2630
+ name = "zeroize"
2631
+ version = "1.8.2"
2632
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2633
+ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
2634
+
2635
+ [[package]]
2636
+ name = "zerotrie"
2637
+ version = "0.2.4"
2638
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2639
+ checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
2640
+ dependencies = [
2641
+ "displaydoc",
2642
+ "yoke",
2643
+ "zerofrom",
2644
+ ]
2645
+
2646
+ [[package]]
2647
+ name = "zerovec"
2648
+ version = "0.11.6"
2649
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2650
+ checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
2651
+ dependencies = [
2652
+ "yoke",
2653
+ "zerofrom",
2654
+ "zerovec-derive",
2655
+ ]
2656
+
2657
+ [[package]]
2658
+ name = "zerovec-derive"
2659
+ version = "0.11.3"
2660
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2661
+ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
2662
+ dependencies = [
2663
+ "proc-macro2",
2664
+ "quote",
2665
+ "syn",
2666
+ ]
2667
+
2668
+ [[package]]
2669
+ name = "zmij"
2670
+ version = "1.0.21"
2671
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2672
+ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "solverforge-fsr"
3
+ version = "2.0.0"
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.13.1", features = ["serde", "console", "verbose-logging"] }
14
+ solverforge-core = { version = "0.13.1" }
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,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.13.1`
59
+ - SolverForge core helpers: `solverforge-core` `0.13.1`
60
+ - Browser UI assets: `solverforge-ui` `0.6.5`
61
+ - Routing engine: `solverforge-maps` `2.1.4`
62
+ - Scaffold metadata: `solverforge-cli` `2.0.4` 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.
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
+ - Higher-priority visits have less slack.
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, and score.
173
+ 3. `src/domain/location.rs`, `src/domain/service_visit.rs`, and
174
+ `src/domain/travel_leg.rs`
175
+ The problem facts the solver reads.
176
+ 4. `src/domain/technician_route.rs`
177
+ The planning entity and list variable SolverForge mutates.
178
+ 5. `src/data/data_seed.rs`
179
+ Demo ID, Bergamo data assembly, routing preparation, and cache policy.
180
+ 6. `src/constraints/mod.rs` and `src/constraints/route_metrics.rs`
181
+ The score model and shared route-measurement math.
182
+ 7. `src/constraints/*.rs`
183
+ One business scoring rule per file.
184
+ 8. `src/solver/service.rs`
185
+ Retained-job orchestration over `SolverManager<FieldServicePlan>`.
186
+ 9. `src/api/routes.rs`, `src/api/dto.rs`, `src/api/route_geometry.rs`, and
187
+ `src/api/sse.rs`
188
+ HTTP routes, transport DTOs, route geometry, and live-event streaming.
189
+ 10. `static/app.js` and `static/app-*.js`
190
+ Browser lifecycle, dataset loading, route rendering, maps, tables, and API
191
+ guide.
192
+
193
+ ## Project Shape
194
+
195
+ - `src/domain/`
196
+ Planning model, domain types, and route entities.
197
+ - `src/constraints/`
198
+ Incremental SolverForge scoring rules and route metric helpers.
199
+ - `src/data/`
200
+ Deterministic Bergamo demo data and road-network preparation.
201
+ - `src/solver/`
202
+ Retained-job facade and runtime event payload formatting.
203
+ - `src/api/`
204
+ Axum routes, DTOs, route geometry, and SSE endpoint.
205
+ - `static/`
206
+ Browser workspace built on stock `solverforge-ui` assets.
207
+ - `tests/e2e/`
208
+ Playwright browser tests for the served app.
WIREFRAME.md ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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` `1.0.1` list-variable SolverForge app
24
+ for 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 assignments, unreachable legs, missing skills or parts, late visits,
50
+ 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` and calls
69
+ `prepare_routing()`.
70
+ 8. `prepare_routing()` loads or fetches the Bergamo road network, computes the
71
+ full travel matrix, and replaces seed legs with road-network legs.
72
+ 9. `SolverService` starts a retained solve through
73
+ `SolverManager<FieldServicePlan>`.
74
+ 10. Solver events are converted by `src/solver/event_payload.rs` into
75
+ UI-facing JSON.
76
+ 11. The browser consumes `/jobs/{id}/events` and fetches snapshots, analysis,
77
+ and route geometry for exact snapshot revisions.
78
+ 12. `src/api/route_geometry.rs` builds map segments, preserving non-routed
79
+ statuses so one unreachable leg does not hide the rest of a route.
80
+
81
+ ## File Map
82
+
83
+ ```text
84
+ .
85
+ ├── Cargo.toml
86
+ │ Rust 1.95 crate metadata for app version 1.0.1 and registry dependency
87
+ │ requests.
88
+ ├── solver.toml
89
+ │ Embedded search policy for list construction and local search.
90
+ ├── solverforge.app.toml
91
+ │ App metadata, demo IDs, model facts/entities, registry dependency sources,
92
+ │ and the `solverforge 0.13.1` runtime target.
93
+ ├── Makefile
94
+ │ Local build, validation, and Space/Docker commands.
95
+ ├── Dockerfile
96
+ │ Multi-stage Rust 1.95 Docker image for Hugging Face Spaces.
97
+ ├── README.md
98
+ │ Run guide, dependency shape, API list, and learning path.
99
+ ├── AGENTS.md
100
+ │ Repo-specific rules for future edits.
101
+ ├── WIREFRAME.md
102
+ │ This architectural walkthrough.
103
+ ├── docs/screenshot.png
104
+ │ Current browser screenshot used by the README.
105
+ ├── src/
106
+ │ ├── domain/
107
+ │ │ `planning_model!` manifest, facts, route entity, and solution.
108
+ │ ├── constraints/
109
+ │ │ Route metric helpers and one score rule per file.
110
+ │ ├── data/
111
+ │ │ Deterministic Bergamo seeds, demo entrypoints, and OSM matrix loading.
112
+ │ ├── solver/
113
+ │ │ Retained-job service and runtime event payload formatting.
114
+ │ └── api/
115
+ │ Axum routes, DTOs, route geometry, and SSE endpoint.
116
+ └── static/
117
+ ├── index.html
118
+ ├── sf-config.json
119
+ ├── generated/ui-model.json
120
+ └── app*.js
121
+ Browser controller, map rendering, route state, layout, and tables.
122
+ ```
123
+
124
+ ## Domain And Route Metrics
125
+
126
+ `src/domain/field_service_plan.rs` owns the public solution shape. It keeps the
127
+ SolverForge model explicit: facts are read-only inputs, while
128
+ `TechnicianRoute.visits` is the one mutable list variable.
129
+
130
+ Route-specific scoring math lives in `src/constraints/route_metrics.rs`. That
131
+ module walks a route from depot to visits to depot, advances a service clock,
132
+ and records reusable counters for the individual constraints.
133
+
134
+ `src/api/route_geometry.rs` is separate because map drawing has a different
135
+ job from scoring. Scoring consumes matrix facts already on the plan; geometry
136
+ loads the road graph to draw visible polylines for a retained snapshot.
137
+
138
+ ## Demo Data
139
+
140
+ `src/data/data_seed.rs` exposes one demo ID:
141
+
142
+ - `STANDARD`
143
+
144
+ The generator is deterministic. It builds two depots, 24 customer locations, 48
145
+ visits, six technicians, and seed self-leg travel facts. Full road-network
146
+ travel facts are prepared when a job is created.
147
+
148
+ ## API And Retained Runtime
149
+
150
+ The REST API handles job control and snapshot reads:
151
+
152
+ - `/jobs` creates a retained solver job.
153
+ - `/jobs/{id}` and `/jobs/{id}/status` expose summary state.
154
+ - `/jobs/{id}/snapshot` returns an exact or latest snapshot.
155
+ - `/jobs/{id}/analysis` runs constraint analysis for a snapshot.
156
+ - `/jobs/{id}/routes` returns route geometry for a snapshot.
157
+ - `/jobs/{id}/events` streams typed lifecycle events.
158
+
159
+ ## Frontend Layout
160
+
161
+ `static/app.js` is the controller. It owns current plan state, retained job
162
+ state, route focus, event handlers, and analysis modal wiring.
163
+
164
+ Supporting modules split the UI by responsibility:
165
+
166
+ - `static/app-dataset.js`
167
+ Demo catalog and plan loading.
168
+ - `static/app-layout.js`
169
+ Page shell and stock SolverForge UI component composition.
170
+ - `static/app-route-state.js`
171
+ Snapshot identity and route geometry cache coordination.
172
+ - `static/app-render*.js`
173
+ Summary cards, route cards, maps, timeline, tables, and API guide.
174
+ - `static/app-utils.js`
175
+ Plan cloning, labels, formatting, and color helpers.
176
+
177
+ ## Validation Surfaces
178
+
179
+ Use the Makefile as the repo-local workflow:
180
+
181
+ - `make fmt-check`
182
+ - `make clippy`
183
+ - `make build-release`
184
+ - `make test`
185
+ - `make test-e2e`
186
+ - `make space-build`
187
+ - `make ci-local`
188
+ - `make pre-release`
189
+
190
+ `make ci-local` includes the Docker image build used by the Hugging Face Space.
191
+ The Playwright command uses the publication bundle's root Node dev dependency;
192
+ 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.0.4"
5
+
6
+ [runtime]
7
+ target = "solverforge 0.13.1"
8
+ runtime_source = "crates.io: solverforge 0.13.1"
9
+ ui_source = "crates.io: solverforge-ui 0.6.5"
10
+ maps_source = "crates.io: solverforge-maps 2.1.4"
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,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ serde_json::from_value(Value::Object(fields))
124
+ }
125
+ }
126
+
127
+ impl TelemetryDto {
128
+ pub fn from_runtime(telemetry: &SolverTelemetry) -> Self {
129
+ Self {
130
+ elapsed_ms: duration_to_millis(telemetry.elapsed),
131
+ step_count: telemetry.step_count,
132
+ moves_generated: telemetry.moves_generated,
133
+ moves_evaluated: telemetry.moves_evaluated,
134
+ moves_accepted: telemetry.moves_accepted,
135
+ score_calculations: telemetry.score_calculations,
136
+ generation_ms: duration_to_millis(telemetry.generation_time),
137
+ evaluation_ms: duration_to_millis(telemetry.evaluation_time),
138
+ moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed),
139
+ acceptance_rate: derive_acceptance_rate(
140
+ telemetry.moves_accepted,
141
+ telemetry.moves_evaluated,
142
+ ),
143
+ }
144
+ }
145
+ }
146
+
147
+ impl JobSummaryDto {
148
+ pub fn from_status(job_id: usize, status: &SolverStatus<HardSoftScore>) -> Self {
149
+ Self {
150
+ id: job_id.to_string(),
151
+ job_id: job_id.to_string(),
152
+ lifecycle_state: lifecycle_state_label(status.lifecycle_state),
153
+ terminal_reason: status.terminal_reason.map(terminal_reason_label),
154
+ checkpoint_available: status.checkpoint_available,
155
+ event_sequence: status.event_sequence,
156
+ snapshot_revision: status.latest_snapshot_revision,
157
+ current_score: status.current_score.map(|score| score.to_string()),
158
+ best_score: status.best_score.map(|score| score.to_string()),
159
+ telemetry: TelemetryDto::from_runtime(&status.telemetry),
160
+ }
161
+ }
162
+ }
163
+
164
+ impl JobSnapshotDto {
165
+ pub fn from_snapshot(snapshot: &SolverSnapshot<FieldServicePlan>) -> Self {
166
+ Self {
167
+ id: snapshot.job_id.to_string(),
168
+ job_id: snapshot.job_id.to_string(),
169
+ snapshot_revision: snapshot.snapshot_revision,
170
+ lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
171
+ terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
172
+ current_score: snapshot.current_score.map(|score| score.to_string()),
173
+ best_score: snapshot.best_score.map(|score| score.to_string()),
174
+ telemetry: TelemetryDto::from_runtime(&snapshot.telemetry),
175
+ solution: PlanDto::from_plan(&snapshot.solution),
176
+ }
177
+ }
178
+ }
179
+
180
+ impl JobAnalysisDto {
181
+ pub fn from_snapshot_analysis(
182
+ snapshot: &SolverSnapshotAnalysis<HardSoftScore>,
183
+ analysis: AnalyzeResponse,
184
+ ) -> Self {
185
+ Self {
186
+ id: snapshot.job_id.to_string(),
187
+ job_id: snapshot.job_id.to_string(),
188
+ snapshot_revision: snapshot.snapshot_revision,
189
+ lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
190
+ terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
191
+ analysis,
192
+ }
193
+ }
194
+ }
195
+
196
+ pub fn analysis_response(analysis: &solverforge::ScoreAnalysis<HardSoftScore>) -> AnalyzeResponse {
197
+ AnalyzeResponse {
198
+ score: analysis.score.to_string(),
199
+ constraints: analysis
200
+ .constraints
201
+ .iter()
202
+ .map(|constraint| ConstraintAnalysisDto {
203
+ name: constraint.name.clone(),
204
+ weight: constraint.weight.to_string(),
205
+ score: constraint.score.to_string(),
206
+ match_count: constraint.match_count,
207
+ })
208
+ .collect(),
209
+ }
210
+ }
211
+
212
+ pub fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str {
213
+ match state {
214
+ SolverLifecycleState::Solving => "SOLVING",
215
+ SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED",
216
+ SolverLifecycleState::Paused => "PAUSED",
217
+ SolverLifecycleState::Completed => "COMPLETED",
218
+ SolverLifecycleState::Cancelled => "CANCELLED",
219
+ SolverLifecycleState::Failed => "FAILED",
220
+ }
221
+ }
222
+
223
+ pub fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str {
224
+ match reason {
225
+ SolverTerminalReason::Completed => "completed",
226
+ SolverTerminalReason::TerminatedByConfig => "terminated_by_config",
227
+ SolverTerminalReason::Cancelled => "cancelled",
228
+ SolverTerminalReason::Failed => "failed",
229
+ }
230
+ }
231
+
232
+ fn duration_to_millis(duration: Duration) -> u64 {
233
+ duration.as_millis().min(u128::from(u64::MAX)) as u64
234
+ }
235
+
236
+ fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 {
237
+ let nanos = elapsed.as_nanos();
238
+ if nanos == 0 {
239
+ 0
240
+ } else {
241
+ let per_second = u128::from(count)
242
+ .saturating_mul(1_000_000_000)
243
+ .checked_div(nanos)
244
+ .unwrap_or(0);
245
+ per_second.min(u128::from(u64::MAX)) as u64
246
+ }
247
+ }
248
+
249
+ fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 {
250
+ if moves_evaluated == 0 {
251
+ 0.0
252
+ } else {
253
+ moves_accepted as f64 / moves_evaluated as f64
254
+ }
255
+ }
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,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Assignment-coverage rule for field-service visits.
2
+ //!
3
+ //! List variables should contain each service visit exactly once. This rule
4
+ //! catches three beginner-relevant failures: a missing visit, a duplicated visit,
5
+ //! and a route list entry that points outside the visit collection.
6
+
7
+ use crate::domain::FieldServicePlan;
8
+ use solverforge::prelude::*;
9
+ use solverforge::IncrementalConstraint;
10
+ use solverforge_core::ConstraintRef;
11
+
12
+ /// HARD: every service visit must appear exactly once in a technician route.
13
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
14
+ AssignedVisitsConstraint::new()
15
+ }
16
+
17
+ struct AssignedVisitsConstraint {
18
+ constraint_ref: ConstraintRef,
19
+ }
20
+
21
+ impl AssignedVisitsConstraint {
22
+ fn new() -> Self {
23
+ Self {
24
+ constraint_ref: ConstraintRef::new("field_service_routing", "Assigned Visits"),
25
+ }
26
+ }
27
+ }
28
+
29
+ #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30
+ struct AssignmentIssues {
31
+ unassigned: i64,
32
+ duplicate_assignments: i64,
33
+ invalid_assignments: i64,
34
+ }
35
+
36
+ impl AssignmentIssues {
37
+ fn total(self) -> i64 {
38
+ self.unassigned + self.duplicate_assignments + self.invalid_assignments
39
+ }
40
+ }
41
+
42
+ impl IncrementalConstraint<FieldServicePlan, HardSoftScore> for AssignedVisitsConstraint {
43
+ fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore {
44
+ HardSoftScore::of(-assignment_issues(solution).total(), 0)
45
+ }
46
+
47
+ fn match_count(&self, solution: &FieldServicePlan) -> usize {
48
+ assignment_issues(solution).total() as usize
49
+ }
50
+
51
+ fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore {
52
+ self.evaluate(solution)
53
+ }
54
+
55
+ fn on_insert(
56
+ &mut self,
57
+ solution: &FieldServicePlan,
58
+ _entity_index: usize,
59
+ _descriptor_index: usize,
60
+ ) -> HardSoftScore {
61
+ self.evaluate(solution)
62
+ }
63
+
64
+ fn on_retract(
65
+ &mut self,
66
+ solution: &FieldServicePlan,
67
+ _entity_index: usize,
68
+ _descriptor_index: usize,
69
+ ) -> HardSoftScore {
70
+ -self.evaluate(solution)
71
+ }
72
+
73
+ fn reset(&mut self) {}
74
+
75
+ fn name(&self) -> &str {
76
+ &self.constraint_ref.name
77
+ }
78
+
79
+ fn is_hard(&self) -> bool {
80
+ true
81
+ }
82
+
83
+ fn weight(&self) -> HardSoftScore {
84
+ HardSoftScore::of(1, 0)
85
+ }
86
+
87
+ fn constraint_ref(&self) -> &ConstraintRef {
88
+ &self.constraint_ref
89
+ }
90
+ }
91
+
92
+ fn assignment_issues(plan: &FieldServicePlan) -> AssignmentIssues {
93
+ // `counts[i]` records how often service visit `i` appears across every
94
+ // technician route. A valid list-variable solution leaves every count at 1.
95
+ let mut counts = vec![0usize; plan.service_visits.len()];
96
+ let mut issues = AssignmentIssues::default();
97
+
98
+ for route in &plan.technician_routes {
99
+ for &visit_idx in &route.visits {
100
+ if let Some(count) = counts.get_mut(visit_idx) {
101
+ *count += 1;
102
+ } else {
103
+ issues.invalid_assignments += 1;
104
+ }
105
+ }
106
+ }
107
+
108
+ for count in counts {
109
+ match count {
110
+ 0 => issues.unassigned += 1,
111
+ 1 => {}
112
+ extra => issues.duplicate_assignments += (extra - 1) as i64,
113
+ }
114
+ }
115
+
116
+ issues
117
+ }
118
+
119
+ #[cfg(test)]
120
+ mod tests {
121
+ use super::*;
122
+ use crate::domain::{
123
+ FieldServicePlan, ServiceVisit, ServiceVisitInit, TechnicianRoute, TechnicianRouteInit,
124
+ };
125
+ use solverforge::IncrementalConstraint;
126
+
127
+ #[test]
128
+ fn empty_routes_are_penalized_for_unassigned_visits() {
129
+ let score = constraint().evaluate(&sample_plan(vec![vec![]]));
130
+
131
+ assert_eq!(score, HardSoftScore::of(-2, 0));
132
+ }
133
+
134
+ #[test]
135
+ fn every_visit_once_is_feasible() {
136
+ let score = constraint().evaluate(&sample_plan(vec![vec![0, 1]]));
137
+
138
+ assert_eq!(score, HardSoftScore::ZERO);
139
+ }
140
+
141
+ #[test]
142
+ fn duplicate_or_invalid_visit_indexes_are_hard_issues() {
143
+ let score = constraint().evaluate(&sample_plan(vec![vec![0, 0, 99]]));
144
+
145
+ assert_eq!(score, HardSoftScore::of(-3, 0));
146
+ }
147
+
148
+ fn sample_plan(route_visits: Vec<Vec<usize>>) -> FieldServicePlan {
149
+ let service_visits = (0..2)
150
+ .map(|idx| {
151
+ ServiceVisit::new(ServiceVisitInit {
152
+ id: format!("visit-{idx}"),
153
+ name: format!("Visit {idx}"),
154
+ customer: format!("Customer {idx}"),
155
+ location_idx: idx,
156
+ duration_minutes: 30,
157
+ earliest_minute: 480,
158
+ latest_minute: 1020,
159
+ required_skill_mask: 0,
160
+ required_parts_mask: 0,
161
+ priority: 1,
162
+ territory: "center".to_string(),
163
+ })
164
+ })
165
+ .collect();
166
+ let technician_routes = route_visits
167
+ .into_iter()
168
+ .enumerate()
169
+ .map(|(idx, visits)| {
170
+ let mut route = TechnicianRoute::new(TechnicianRouteInit {
171
+ id: format!("route-{idx}"),
172
+ technician_id: format!("tech-{idx}"),
173
+ technician_name: format!("Tech {idx}"),
174
+ color: "#2563eb".to_string(),
175
+ start_location_idx: 0,
176
+ end_location_idx: 0,
177
+ shift_start_minute: 480,
178
+ shift_end_minute: 1020,
179
+ max_route_minutes: 480,
180
+ skill_mask: 0,
181
+ inventory_mask: 0,
182
+ territory: "center".to_string(),
183
+ });
184
+ route.visits = visits;
185
+ route
186
+ })
187
+ .collect();
188
+
189
+ FieldServicePlan::new(Vec::new(), service_visits, Vec::new(), technician_routes)
190
+ }
191
+ }
src/constraints/balance_workload.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::constraints::route_metrics::{
2
+ balance_workload_match_count, balance_workload_score, RouteConstraint,
3
+ };
4
+ use crate::domain::FieldServicePlan;
5
+ use solverforge::prelude::*;
6
+ use solverforge::IncrementalConstraint;
7
+
8
+ /// SOFT: discourage concentrating all service and travel minutes on one route.
9
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
10
+ RouteConstraint::new(
11
+ "Balance Workload",
12
+ false,
13
+ HardSoftScore::of(0, 1),
14
+ balance_workload_score,
15
+ balance_workload_match_count,
16
+ )
17
+ }
src/constraints/minimize_travel.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::constraints::route_metrics::{
2
+ minimize_travel_match_count, minimize_travel_score, RouteConstraint,
3
+ };
4
+ use crate::domain::FieldServicePlan;
5
+ use solverforge::prelude::*;
6
+ use solverforge::IncrementalConstraint;
7
+
8
+ /// SOFT: minimize road travel time and distance across technician routes.
9
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
10
+ RouteConstraint::new(
11
+ "Minimize Travel",
12
+ false,
13
+ HardSoftScore::of(0, 1),
14
+ minimize_travel_score,
15
+ minimize_travel_match_count,
16
+ )
17
+ }
src/constraints/mod.rs ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Constraint assembly for field-service routing.
2
+ //!
3
+ //! Each child module owns one business rule. The small wrappers delegate shared
4
+ //! route walking to `route_metrics`, so beginner readers can learn the rule
5
+ //! names here before opening the scoring math.
6
+
7
+ use crate::domain::FieldServicePlan;
8
+ use solverforge::prelude::*;
9
+
10
+ pub use self::assemble::create_constraints;
11
+
12
+ mod route_constraint;
13
+ pub mod route_metrics;
14
+ #[cfg(test)]
15
+ mod route_metrics_tests;
16
+
17
+ // @solverforge:begin constraint-modules
18
+ mod assigned_visits;
19
+ mod balance_workload;
20
+ mod minimize_travel;
21
+ mod priority_slack;
22
+ mod reachable_legs;
23
+ mod required_parts;
24
+ mod required_skills;
25
+ mod shift_capacity;
26
+ mod territory_affinity;
27
+ mod time_windows;
28
+ // @solverforge:end constraint-modules
29
+
30
+ mod assemble {
31
+ use super::*;
32
+
33
+ /// Collects the full scoring model used by `FieldServicePlan`.
34
+ pub fn create_constraints() -> impl ConstraintSet<FieldServicePlan, HardSoftScore> {
35
+ // @solverforge:begin constraint-calls
36
+ (
37
+ assigned_visits::constraint(),
38
+ balance_workload::constraint(),
39
+ minimize_travel::constraint(),
40
+ priority_slack::constraint(),
41
+ reachable_legs::constraint(),
42
+ required_parts::constraint(),
43
+ required_skills::constraint(),
44
+ shift_capacity::constraint(),
45
+ territory_affinity::constraint(),
46
+ time_windows::constraint(),
47
+ )
48
+ // @solverforge:end constraint-calls
49
+ }
50
+ }
src/constraints/priority_slack.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::constraints::route_metrics::{
2
+ priority_slack_match_count, priority_slack_score, RouteConstraint,
3
+ };
4
+ use crate::domain::FieldServicePlan;
5
+ use solverforge::prelude::*;
6
+ use solverforge::IncrementalConstraint;
7
+
8
+ /// SOFT: reward serving high-priority visits with slack before their deadline.
9
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
10
+ RouteConstraint::new(
11
+ "Priority Slack",
12
+ false,
13
+ HardSoftScore::of(0, 1),
14
+ priority_slack_score,
15
+ priority_slack_match_count,
16
+ )
17
+ }
src/constraints/reachable_legs.rs ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::constraints::route_metrics::{reachable_match_count, reachable_score, RouteConstraint};
2
+ use crate::domain::FieldServicePlan;
3
+ use solverforge::prelude::*;
4
+ use solverforge::IncrementalConstraint;
5
+
6
+ /// HARD: every depot-to-visit, visit-to-visit, and visit-to-depot leg must be routable.
7
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
8
+ RouteConstraint::new(
9
+ "Reachable Legs",
10
+ true,
11
+ HardSoftScore::of(1, 0),
12
+ reachable_score,
13
+ reachable_match_count,
14
+ )
15
+ }
src/constraints/required_parts.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::constraints::route_metrics::{
2
+ required_parts_match_count, required_parts_score, RouteConstraint,
3
+ };
4
+ use crate::domain::FieldServicePlan;
5
+ use solverforge::prelude::*;
6
+ use solverforge::IncrementalConstraint;
7
+
8
+ /// HARD: route inventory must cover every assigned visit's required parts.
9
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
10
+ RouteConstraint::new(
11
+ "Required Parts",
12
+ true,
13
+ HardSoftScore::of(1, 0),
14
+ required_parts_score,
15
+ required_parts_match_count,
16
+ )
17
+ }
src/constraints/required_skills.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::constraints::route_metrics::{
2
+ required_skills_match_count, required_skills_score, RouteConstraint,
3
+ };
4
+ use crate::domain::FieldServicePlan;
5
+ use solverforge::prelude::*;
6
+ use solverforge::IncrementalConstraint;
7
+
8
+ /// HARD: a technician route may only contain visits whose skill mask is covered.
9
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
10
+ RouteConstraint::new(
11
+ "Required Skills",
12
+ true,
13
+ HardSoftScore::of(1, 0),
14
+ required_skills_score,
15
+ required_skills_match_count,
16
+ )
17
+ }
src/constraints/route_constraint.rs ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Small reusable incremental constraint wrapper for route-level rules.
2
+ //!
3
+ //! Most FSR rules score one technician route at a time. This adapter keeps that
4
+ //! pattern explicit: a rule provides a route scorer and match counter, while
5
+ //! SolverForge calls `on_insert` and `on_retract` when a route entity changes.
6
+
7
+ use crate::domain::{FieldServicePlan, TechnicianRoute};
8
+ use solverforge::prelude::*;
9
+ use solverforge::IncrementalConstraint;
10
+ use solverforge_core::ConstraintRef;
11
+
12
+ pub struct RouteConstraint {
13
+ constraint_ref: ConstraintRef,
14
+ hard: bool,
15
+ weight: HardSoftScore,
16
+ scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
17
+ match_counter: fn(&FieldServicePlan, &TechnicianRoute) -> usize,
18
+ }
19
+
20
+ impl RouteConstraint {
21
+ /// Creates a named route-level scoring rule.
22
+ pub fn new(
23
+ name: &'static str,
24
+ hard: bool,
25
+ weight: HardSoftScore,
26
+ scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
27
+ match_counter: fn(&FieldServicePlan, &TechnicianRoute) -> usize,
28
+ ) -> Self {
29
+ Self {
30
+ constraint_ref: ConstraintRef::new("field_service_routing", name),
31
+ hard,
32
+ weight,
33
+ scorer,
34
+ match_counter,
35
+ }
36
+ }
37
+
38
+ /// Computes only the changed route's score for incremental callbacks.
39
+ fn route_score(&self, solution: &FieldServicePlan, entity_index: usize) -> HardSoftScore {
40
+ solution
41
+ .technician_routes
42
+ .get(entity_index)
43
+ .map(|route| (self.scorer)(solution, route))
44
+ .unwrap_or(HardSoftScore::ZERO)
45
+ }
46
+ }
47
+
48
+ impl IncrementalConstraint<FieldServicePlan, HardSoftScore> for RouteConstraint {
49
+ fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore {
50
+ solution
51
+ .technician_routes
52
+ .iter()
53
+ .map(|route| (self.scorer)(solution, route))
54
+ .fold(HardSoftScore::ZERO, |total, score| total + score)
55
+ }
56
+
57
+ fn match_count(&self, solution: &FieldServicePlan) -> usize {
58
+ solution
59
+ .technician_routes
60
+ .iter()
61
+ .map(|route| (self.match_counter)(solution, route))
62
+ .sum()
63
+ }
64
+
65
+ fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore {
66
+ self.evaluate(solution)
67
+ }
68
+
69
+ fn on_insert(
70
+ &mut self,
71
+ solution: &FieldServicePlan,
72
+ entity_index: usize,
73
+ _descriptor_index: usize,
74
+ ) -> HardSoftScore {
75
+ self.route_score(solution, entity_index)
76
+ }
77
+
78
+ fn on_retract(
79
+ &mut self,
80
+ solution: &FieldServicePlan,
81
+ entity_index: usize,
82
+ _descriptor_index: usize,
83
+ ) -> HardSoftScore {
84
+ -self.route_score(solution, entity_index)
85
+ }
86
+
87
+ fn reset(&mut self) {}
88
+
89
+ fn name(&self) -> &str {
90
+ &self.constraint_ref.name
91
+ }
92
+
93
+ fn is_hard(&self) -> bool {
94
+ self.hard
95
+ }
96
+
97
+ fn weight(&self) -> HardSoftScore {
98
+ self.weight
99
+ }
100
+
101
+ fn constraint_ref(&self) -> &ConstraintRef {
102
+ &self.constraint_ref
103
+ }
104
+ }
src/constraints/route_metrics.rs ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Shared route measurements used by field-service constraints.
2
+ //!
3
+ //! SolverForge calls each constraint separately, but the business concepts
4
+ //! overlap: travel, time windows, skills, parts, overtime, and priority slack
5
+ //! all require walking the same ordered visit list. This module centralizes that
6
+ //! walk so the individual constraint files stay easy to read.
7
+
8
+ use crate::domain::{FieldServicePlan, ServiceVisit, TechnicianRoute, TravelLeg};
9
+ use solverforge::prelude::*;
10
+
11
+ pub use super::route_constraint::RouteConstraint;
12
+
13
+ /// Aggregated measurements for one technician route.
14
+ ///
15
+ /// Individual constraints reuse this struct so each business rule can stay
16
+ /// small. For example, the time-window constraint reads `late_minutes`, while
17
+ /// the travel minimization rule reads `travel_seconds` and `distance_meters`.
18
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
19
+ pub struct RouteStats {
20
+ pub invalid_visits: i64,
21
+ pub valid_visits: i64,
22
+ pub scored_travel_legs: i64,
23
+ pub unreachable_legs: i64,
24
+ pub missing_skill_visits: i64,
25
+ pub missing_part_visits: i64,
26
+ pub late_visits: i64,
27
+ pub late_minutes: i64,
28
+ pub overtime_minutes: i64,
29
+ pub travel_seconds: i64,
30
+ pub distance_meters: i64,
31
+ pub service_minutes: i64,
32
+ pub waiting_minutes: i64,
33
+ pub route_minutes: i64,
34
+ pub finish_minute: i32,
35
+ pub territory_matches: i64,
36
+ pub priority_slack: i64,
37
+ }
38
+
39
+ impl RouteStats {
40
+ pub fn travel_minutes(&self) -> i64 {
41
+ div_ceil(self.travel_seconds, 60)
42
+ }
43
+ }
44
+
45
+ #[derive(Debug, Clone, Copy)]
46
+ struct VisitTiming {
47
+ visit_idx: usize,
48
+ service_start: i32,
49
+ }
50
+
51
+ pub fn route_stats(plan: &FieldServicePlan, route: &TechnicianRoute) -> RouteStats {
52
+ let mut stats = RouteStats {
53
+ finish_minute: route.shift_start_minute,
54
+ ..RouteStats::default()
55
+ };
56
+ let mut clock = route.shift_start_minute;
57
+ let mut previous_location = route.start_location_idx;
58
+ let mut timings = Vec::with_capacity(route.visits.len());
59
+
60
+ // Walk the route in visit order. This mirrors how a technician would drive:
61
+ // depot to first visit, visit to visit, then back to the end depot.
62
+ for &visit_idx in &route.visits {
63
+ let Some(visit) = plan.service_visits.get(visit_idx) else {
64
+ stats.invalid_visits += 1;
65
+ continue;
66
+ };
67
+ stats.valid_visits += 1;
68
+
69
+ apply_leg(
70
+ plan,
71
+ previous_location,
72
+ visit.location_idx,
73
+ &mut clock,
74
+ &mut stats,
75
+ );
76
+
77
+ // Waiting is allowed and soft-neutral; lateness is a hard feasibility
78
+ // problem scored by the time-window constraint.
79
+ if clock < visit.earliest_minute {
80
+ stats.waiting_minutes += i64::from(visit.earliest_minute - clock);
81
+ clock = visit.earliest_minute;
82
+ }
83
+ if clock > visit.latest_minute {
84
+ stats.late_visits += 1;
85
+ stats.late_minutes += i64::from(clock - visit.latest_minute);
86
+ }
87
+
88
+ if !mask_contains(route.skill_mask, visit.required_skill_mask) {
89
+ stats.missing_skill_visits += 1;
90
+ }
91
+ if !mask_contains(route.inventory_mask, visit.required_parts_mask) {
92
+ stats.missing_part_visits += 1;
93
+ }
94
+ if route.territory == visit.territory {
95
+ stats.territory_matches += 1;
96
+ }
97
+
98
+ timings.push(VisitTiming {
99
+ visit_idx,
100
+ service_start: clock,
101
+ });
102
+
103
+ let service_minutes = visit.duration_minutes.max(0);
104
+ stats.service_minutes += i64::from(service_minutes);
105
+ clock = clock.saturating_add(service_minutes);
106
+ previous_location = visit.location_idx;
107
+ }
108
+
109
+ apply_leg(
110
+ plan,
111
+ previous_location,
112
+ route.end_location_idx,
113
+ &mut clock,
114
+ &mut stats,
115
+ );
116
+
117
+ stats.finish_minute = clock;
118
+ stats.route_minutes = i64::from(clock.saturating_sub(route.shift_start_minute));
119
+ stats.overtime_minutes = i64::from((clock - route.shift_end_minute).max(0))
120
+ + (stats.route_minutes - i64::from(route.max_route_minutes)).max(0);
121
+ stats.priority_slack = priority_slack(plan, &timings);
122
+ stats
123
+ }
124
+
125
+ pub fn reachable_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
126
+ let stats = route_stats(plan, route);
127
+ HardSoftScore::of(-(stats.invalid_visits + stats.unreachable_legs), 0)
128
+ }
129
+
130
+ pub fn reachable_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
131
+ let stats = route_stats(plan, route);
132
+ count_matches(stats.invalid_visits + stats.unreachable_legs)
133
+ }
134
+
135
+ pub fn required_skills_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
136
+ HardSoftScore::of(-route_stats(plan, route).missing_skill_visits, 0)
137
+ }
138
+
139
+ pub fn required_skills_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
140
+ count_matches(route_stats(plan, route).missing_skill_visits)
141
+ }
142
+
143
+ pub fn required_parts_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
144
+ HardSoftScore::of(-route_stats(plan, route).missing_part_visits, 0)
145
+ }
146
+
147
+ pub fn required_parts_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
148
+ count_matches(route_stats(plan, route).missing_part_visits)
149
+ }
150
+
151
+ pub fn time_windows_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
152
+ HardSoftScore::of(-route_stats(plan, route).late_minutes, 0)
153
+ }
154
+
155
+ pub fn time_windows_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
156
+ count_matches(route_stats(plan, route).late_visits)
157
+ }
158
+
159
+ pub fn shift_capacity_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
160
+ HardSoftScore::of(-route_stats(plan, route).overtime_minutes, 0)
161
+ }
162
+
163
+ pub fn shift_capacity_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
164
+ usize::from(route_stats(plan, route).overtime_minutes > 0)
165
+ }
166
+
167
+ pub fn minimize_travel_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
168
+ let stats = route_stats(plan, route);
169
+ let travel_minutes = stats.travel_minutes();
170
+ let travel_km = div_ceil(stats.distance_meters, 1_000);
171
+ HardSoftScore::of(0, -(travel_minutes + travel_km))
172
+ }
173
+
174
+ pub fn minimize_travel_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
175
+ count_matches(route_stats(plan, route).scored_travel_legs)
176
+ }
177
+
178
+ pub fn balance_workload_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
179
+ let normalized = (route_stats(plan, route).route_minutes / 15).max(0);
180
+ HardSoftScore::of(0, -(normalized * normalized))
181
+ }
182
+
183
+ pub fn balance_workload_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
184
+ usize::from(route_stats(plan, route).route_minutes > 0)
185
+ }
186
+
187
+ pub fn territory_affinity_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
188
+ HardSoftScore::of(0, route_stats(plan, route).territory_matches * 25)
189
+ }
190
+
191
+ pub fn territory_affinity_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
192
+ count_matches(route_stats(plan, route).territory_matches)
193
+ }
194
+
195
+ pub fn priority_slack_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
196
+ HardSoftScore::of(0, route_stats(plan, route).priority_slack)
197
+ }
198
+
199
+ pub fn priority_slack_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
200
+ count_matches(route_stats(plan, route).valid_visits)
201
+ }
202
+
203
+ pub fn leg_for(
204
+ plan: &FieldServicePlan,
205
+ from_location_idx: usize,
206
+ to_location_idx: usize,
207
+ ) -> Option<&TravelLeg> {
208
+ let width = plan.locations.len();
209
+ // Travel legs are normally stored as a dense row-major matrix. The secondary
210
+ // scan keeps tests and sparse diagnostics readable without changing the
211
+ // public fact shape.
212
+ let direct_idx = from_location_idx
213
+ .checked_mul(width)?
214
+ .checked_add(to_location_idx)?;
215
+
216
+ if let Some(leg) = plan.travel_legs.get(direct_idx) {
217
+ if leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx {
218
+ return Some(leg);
219
+ }
220
+ }
221
+
222
+ plan.travel_legs.iter().find(|leg| {
223
+ leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx
224
+ })
225
+ }
226
+
227
+ fn apply_leg(
228
+ plan: &FieldServicePlan,
229
+ from_location_idx: usize,
230
+ to_location_idx: usize,
231
+ clock: &mut i32,
232
+ stats: &mut RouteStats,
233
+ ) {
234
+ let Some(leg) = leg_for(plan, from_location_idx, to_location_idx) else {
235
+ stats.unreachable_legs += 1;
236
+ return;
237
+ };
238
+
239
+ if !leg.reachable {
240
+ stats.unreachable_legs += 1;
241
+ return;
242
+ }
243
+
244
+ // Scoring uses seconds for precision but the route clock advances in whole
245
+ // minutes because visits and shifts are modeled on a minute calendar.
246
+ stats.travel_seconds += leg.duration_seconds.max(0);
247
+ stats.distance_meters += leg.distance_meters.max(0);
248
+ if leg.duration_seconds > 0 || leg.distance_meters > 0 {
249
+ stats.scored_travel_legs += 1;
250
+ }
251
+ *clock = clock.saturating_add(div_ceil(leg.duration_seconds.max(0), 60) as i32);
252
+ }
253
+
254
+ fn priority_slack(plan: &FieldServicePlan, timings: &[VisitTiming]) -> i64 {
255
+ timings
256
+ .iter()
257
+ .filter_map(|timing| {
258
+ plan.service_visits
259
+ .get(timing.visit_idx)
260
+ .map(|visit| visit_priority_slack(visit, timing.service_start))
261
+ })
262
+ .sum()
263
+ }
264
+
265
+ fn visit_priority_slack(visit: &ServiceVisit, service_start: i32) -> i64 {
266
+ let slack_quarters = i64::from((visit.latest_minute - service_start).max(0) / 15);
267
+ i64::from(visit.priority.max(1)) * (slack_quarters + 1)
268
+ }
269
+
270
+ fn mask_contains(available: i64, required: i64) -> bool {
271
+ (available & required) == required
272
+ }
273
+
274
+ fn div_ceil(value: i64, divisor: i64) -> i64 {
275
+ if value <= 0 {
276
+ 0
277
+ } else {
278
+ (value + divisor - 1) / divisor
279
+ }
280
+ }
281
+
282
+ fn count_matches(value: i64) -> usize {
283
+ usize::try_from(value.max(0)).unwrap_or(usize::MAX)
284
+ }
src/constraints/route_metrics_tests.rs ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::route_metrics::{leg_for, route_stats};
2
+ use crate::domain::{
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 full_constraint_set_reports_expected_hard_penalties() {
35
+ let constraints = crate::constraints::create_constraints();
36
+ let score = constraints.evaluate_all(&sample_plan(vec![0, 1]));
37
+
38
+ assert_eq!(score.hard(), -56);
39
+ assert!(score.soft() < 0);
40
+ }
41
+
42
+ #[test]
43
+ fn route_constraint_match_counts_describe_underlying_route_matches() {
44
+ let constraints = crate::constraints::create_constraints();
45
+ let results = constraints.evaluate_each(&sample_plan(vec![0, 1]));
46
+ let match_count = |name: &str| {
47
+ results
48
+ .iter()
49
+ .find(|result| result.name == name)
50
+ .map(|result| result.match_count)
51
+ .unwrap_or_else(|| panic!("missing constraint result for {name}"))
52
+ };
53
+
54
+ assert_eq!(match_count("Balance Workload"), 1);
55
+ assert_eq!(match_count("Minimize Travel"), 3);
56
+ assert_eq!(match_count("Priority Slack"), 2);
57
+ assert_eq!(match_count("Required Parts"), 1);
58
+ assert_eq!(match_count("Shift Capacity"), 1);
59
+ assert_eq!(match_count("Territory Affinity"), 2);
60
+ }
61
+
62
+ fn sample_plan(visits: Vec<usize>) -> FieldServicePlan {
63
+ let locations = vec![
64
+ Location::new(
65
+ "loc-0",
66
+ "Hub",
67
+ "Hub".to_string(),
68
+ 45_700_000,
69
+ 9_670_000,
70
+ "depot".to_string(),
71
+ ),
72
+ Location::new(
73
+ "loc-1",
74
+ "Customer 1",
75
+ "Customer 1".to_string(),
76
+ 45_710_000,
77
+ 9_680_000,
78
+ "customer".to_string(),
79
+ ),
80
+ Location::new(
81
+ "loc-2",
82
+ "Customer 2",
83
+ "Customer 2".to_string(),
84
+ 45_720_000,
85
+ 9_690_000,
86
+ "customer".to_string(),
87
+ ),
88
+ ];
89
+ let service_visits = vec![
90
+ ServiceVisit::new(ServiceVisitInit {
91
+ id: "visit-0".to_string(),
92
+ name: "Boiler".to_string(),
93
+ customer: "Customer 1".to_string(),
94
+ location_idx: 1,
95
+ duration_minutes: 30,
96
+ earliest_minute: 510,
97
+ latest_minute: 540,
98
+ required_skill_mask: 0b001,
99
+ required_parts_mask: 0b010,
100
+ priority: 3,
101
+ territory: "center".to_string(),
102
+ }),
103
+ ServiceVisit::new(ServiceVisitInit {
104
+ id: "visit-1".to_string(),
105
+ name: "Lift".to_string(),
106
+ customer: "Customer 2".to_string(),
107
+ location_idx: 2,
108
+ duration_minutes: 45,
109
+ earliest_minute: 540,
110
+ latest_minute: 570,
111
+ required_skill_mask: 0b001,
112
+ required_parts_mask: 0b100,
113
+ priority: 2,
114
+ territory: "center".to_string(),
115
+ }),
116
+ ];
117
+ let travel_legs = row_major_legs(3);
118
+ let mut route = TechnicianRoute::new(TechnicianRouteInit {
119
+ id: "route-0".to_string(),
120
+ technician_id: "tech-0".to_string(),
121
+ technician_name: "Ada".to_string(),
122
+ color: "#2563eb".to_string(),
123
+ start_location_idx: 0,
124
+ end_location_idx: 0,
125
+ shift_start_minute: 480,
126
+ shift_end_minute: 585,
127
+ max_route_minutes: 90,
128
+ skill_mask: 0b001,
129
+ inventory_mask: 0b010,
130
+ territory: "center".to_string(),
131
+ });
132
+ route.visits = visits;
133
+
134
+ FieldServicePlan::new(locations, service_visits, travel_legs, vec![route])
135
+ }
136
+
137
+ fn row_major_legs(width: usize) -> Vec<TravelLeg> {
138
+ (0..width)
139
+ .flat_map(|from| {
140
+ (0..width).map(move |to| {
141
+ let same = from == to;
142
+ TravelLeg::new(TravelLegInit {
143
+ id: format!("leg-{from}-{to}"),
144
+ name: format!("leg-{from}-{to}"),
145
+ from_location_idx: from,
146
+ to_location_idx: to,
147
+ duration_seconds: if same { 0 } else { 600 },
148
+ distance_meters: if same { 0 } else { 2_000 },
149
+ reachable: true,
150
+ })
151
+ })
152
+ })
153
+ .collect()
154
+ }
src/constraints/shift_capacity.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::constraints::route_metrics::{
2
+ shift_capacity_match_count, shift_capacity_score, RouteConstraint,
3
+ };
4
+ use crate::domain::FieldServicePlan;
5
+ use solverforge::prelude::*;
6
+ use solverforge::IncrementalConstraint;
7
+
8
+ /// HARD: the complete route must fit inside the technician shift and route cap.
9
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
10
+ RouteConstraint::new(
11
+ "Shift Capacity",
12
+ true,
13
+ HardSoftScore::of(1, 0),
14
+ shift_capacity_score,
15
+ shift_capacity_match_count,
16
+ )
17
+ }
src/constraints/territory_affinity.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::constraints::route_metrics::{
2
+ territory_affinity_match_count, territory_affinity_score, RouteConstraint,
3
+ };
4
+ use crate::domain::FieldServicePlan;
5
+ use solverforge::prelude::*;
6
+ use solverforge::IncrementalConstraint;
7
+
8
+ /// SOFT: prefer visits inside the technician's familiar territory.
9
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
10
+ RouteConstraint::new(
11
+ "Territory Affinity",
12
+ false,
13
+ HardSoftScore::of(0, 1),
14
+ territory_affinity_score,
15
+ territory_affinity_match_count,
16
+ )
17
+ }
src/constraints/time_windows.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::constraints::route_metrics::{
2
+ time_windows_match_count, time_windows_score, RouteConstraint,
3
+ };
4
+ use crate::domain::FieldServicePlan;
5
+ use solverforge::prelude::*;
6
+ use solverforge::IncrementalConstraint;
7
+
8
+ /// HARD: each visit must start no later than its latest service minute.
9
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
10
+ RouteConstraint::new(
11
+ "Time Windows",
12
+ true,
13
+ HardSoftScore::of(1, 0),
14
+ time_windows_score,
15
+ time_windows_match_count,
16
+ )
17
+ }
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,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ Ok(())
139
+ }
140
+
141
+ pub async fn load_network() -> Result<NetworkRef, DemoDataError> {
142
+ RoadNetwork::load_or_fetch(&BERGAMO_BBOX, &network_config(), None)
143
+ .await
144
+ .map_err(DemoDataError::from)
145
+ }
146
+
147
+ pub fn network_config() -> NetworkConfig {
148
+ NetworkConfig::default()
149
+ .cache_dir(PathBuf::from(".osm_cache/field-service-routing/bergamo"))
150
+ .connect_timeout(Duration::from_secs(10))
151
+ .read_timeout(Duration::from_secs(30))
152
+ .overpass_max_retries(1)
153
+ .overpass_retry_backoff(Duration::from_secs(2))
154
+ }
155
+
156
+ fn build_locations(demo: DemoData) -> Vec<Location> {
157
+ let service_location_count = demo.visit_count().min(SERVICE_LOCATIONS.len());
158
+
159
+ DEPOTS
160
+ .iter()
161
+ .map(|seed| seed.to_location("depot"))
162
+ .chain(
163
+ SERVICE_LOCATIONS
164
+ .iter()
165
+ .take(service_location_count)
166
+ .map(|seed| seed.to_location("customer")),
167
+ )
168
+ .collect()
169
+ }
170
+
171
+ fn build_service_visits(demo: DemoData) -> Vec<ServiceVisit> {
172
+ (0..demo.visit_count())
173
+ .map(|idx| {
174
+ let seed = &SERVICE_LOCATIONS[idx % SERVICE_LOCATIONS.len()];
175
+ let profile = VISIT_PROFILES[idx % VISIT_PROFILES.len()];
176
+ ServiceVisit::new(ServiceVisitInit {
177
+ id: format!("visit-{idx:02}"),
178
+ name: profile.name.to_string(),
179
+ customer: seed.label.to_string(),
180
+ location_idx: DEPOTS.len() + (idx % SERVICE_LOCATIONS.len()),
181
+ duration_minutes: profile.duration_minutes,
182
+ earliest_minute: profile.earliest_minute,
183
+ latest_minute: profile.latest_minute,
184
+ required_skill_mask: profile.required_skill_mask,
185
+ required_parts_mask: profile.required_parts_mask,
186
+ priority: profile.priority,
187
+ territory: seed.territory.to_string(),
188
+ })
189
+ })
190
+ .collect()
191
+ }
192
+
193
+ fn build_technician_routes(demo: DemoData) -> Vec<TechnicianRoute> {
194
+ TECHNICIANS
195
+ .iter()
196
+ .take(demo.technician_count())
197
+ .enumerate()
198
+ .map(|(idx, seed)| {
199
+ TechnicianRoute::new(TechnicianRouteInit {
200
+ id: format!("route-{idx:02}"),
201
+ technician_id: seed.id.to_string(),
202
+ technician_name: seed.name.to_string(),
203
+ color: seed.color.to_string(),
204
+ start_location_idx: seed.start_location_idx,
205
+ end_location_idx: seed.end_location_idx,
206
+ shift_start_minute: 8 * 60,
207
+ shift_end_minute: 18 * 60,
208
+ max_route_minutes: 10 * 60,
209
+ skill_mask: seed.skill_mask,
210
+ inventory_mask: seed.inventory_mask,
211
+ territory: seed.territory.to_string(),
212
+ })
213
+ })
214
+ .collect()
215
+ }
216
+
217
+ fn build_seed_travel_legs(width: usize) -> Vec<TravelLeg> {
218
+ (0..width)
219
+ .map(|idx| {
220
+ TravelLeg::new(TravelLegInit {
221
+ id: format!("leg-{idx:02}-{idx:02}"),
222
+ name: format!("leg-{idx:02}-{idx:02}"),
223
+ from_location_idx: idx,
224
+ to_location_idx: idx,
225
+ duration_seconds: 0,
226
+ distance_meters: 0,
227
+ reachable: true,
228
+ })
229
+ })
230
+ .collect()
231
+ }
232
+
233
+ fn build_travel_legs(matrix: &solverforge_maps::TravelTimeMatrix, width: usize) -> Vec<TravelLeg> {
234
+ let mut legs = Vec::with_capacity(width * width);
235
+
236
+ for from in 0..width {
237
+ for to in 0..width {
238
+ let (duration_seconds, distance_meters, reachable) = if from == to {
239
+ (0, 0, true)
240
+ } else {
241
+ let matrix_duration = matrix.get(from, to).unwrap_or(UNREACHABLE);
242
+ let matrix_distance = matrix.distance_meters(from, to).unwrap_or(UNREACHABLE);
243
+ if matrix_duration == UNREACHABLE || matrix_distance == UNREACHABLE {
244
+ (0, 0, false)
245
+ } else {
246
+ (matrix_duration, matrix_distance, true)
247
+ }
248
+ };
249
+
250
+ legs.push(TravelLeg::new(TravelLegInit {
251
+ id: format!("leg-{from:02}-{to:02}"),
252
+ name: format!("leg-{from:02}-{to:02}"),
253
+ from_location_idx: from,
254
+ to_location_idx: to,
255
+ duration_seconds,
256
+ distance_meters,
257
+ reachable,
258
+ }));
259
+ }
260
+ }
261
+
262
+ legs
263
+ }
264
+
265
+ #[cfg(test)]
266
+ mod tests {
267
+ use super::*;
268
+
269
+ #[test]
270
+ fn generated_technician_routes_start_without_assigned_visits() {
271
+ for demo in DemoData::available_demo_data() {
272
+ let routes = build_technician_routes(*demo);
273
+
274
+ assert!(!routes.is_empty());
275
+ assert!(routes.iter().all(|route| route.visits.is_empty()));
276
+ }
277
+ }
278
+
279
+ #[tokio::test]
280
+ async fn generated_seed_plan_has_only_identity_travel_legs() {
281
+ let plan = generate(DemoData::Standard).await.unwrap();
282
+
283
+ assert_eq!(plan.travel_legs.len(), plan.locations.len());
284
+ assert!(plan.travel_legs.iter().enumerate().all(|(idx, leg)| {
285
+ leg.from_location_idx == idx
286
+ && leg.to_location_idx == idx
287
+ && leg.duration_seconds == 0
288
+ && leg.distance_meters == 0
289
+ && leg.reachable
290
+ }));
291
+ }
292
+ }
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,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, Serialize};
8
+ use solverforge::prelude::*;
9
+
10
+ // @solverforge:begin solution-imports
11
+ use super::Location;
12
+ use super::ServiceVisit;
13
+ use super::TechnicianRoute;
14
+ use super::TravelLeg;
15
+ // @solverforge:end solution-imports
16
+
17
+ /// Full planning solution passed to the SolverForge runtime and HTTP API.
18
+ ///
19
+ /// The first three collections are read-only facts. `technician_routes` is the
20
+ /// planning entity collection because each route owns the mutable visit list.
21
+ #[planning_solution(
22
+ constraints = "crate::constraints::create_constraints",
23
+ solver_toml = "../../solver.toml"
24
+ )]
25
+ #[derive(Serialize, Deserialize)]
26
+ pub struct FieldServicePlan {
27
+ // @solverforge:begin solution-collections
28
+ /// All depots and customer sites, addressed by vector index from visits and
29
+ /// route endpoints.
30
+ #[problem_fact_collection]
31
+ pub locations: Vec<Location>,
32
+ /// Customer jobs that must be inserted into technician routes.
33
+ #[problem_fact_collection]
34
+ pub service_visits: Vec<ServiceVisit>,
35
+ /// Directed travel matrix used by constraints and route geometry.
36
+ #[problem_fact_collection]
37
+ pub travel_legs: Vec<TravelLeg>,
38
+ /// Route entities whose `visits` lists are changed by the solver.
39
+ #[planning_entity_collection]
40
+ pub technician_routes: Vec<TechnicianRoute>,
41
+ // @solverforge:end solution-collections
42
+ #[planning_score]
43
+ pub score: Option<HardSoftScore>,
44
+ }
45
+
46
+ impl FieldServicePlan {
47
+ /// Builds a plan from immutable facts and initially empty route entities.
48
+ #[rustfmt::skip]
49
+ pub fn new(
50
+ // @solverforge:begin solution-constructor-params
51
+ locations: Vec<Location>,
52
+ service_visits: Vec<ServiceVisit>,
53
+ travel_legs: Vec<TravelLeg>,
54
+ technician_routes: Vec<TechnicianRoute>,
55
+ // @solverforge:end solution-constructor-params
56
+ ) -> Self {
57
+ Self {
58
+ // @solverforge:begin solution-constructor-init
59
+ locations,
60
+ service_visits,
61
+ travel_legs,
62
+ technician_routes,
63
+ // @solverforge:end solution-constructor-init
64
+ score: None,
65
+ }
66
+ }
67
+ }
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,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ // @solverforge:end domain-exports
26
+ }
src/domain/service_visit.rs ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ pub name: String,
15
+ pub customer: String,
16
+ pub location_idx: usize,
17
+ pub duration_minutes: i32,
18
+ pub earliest_minute: i32,
19
+ pub latest_minute: i32,
20
+ pub required_skill_mask: i64,
21
+ pub required_parts_mask: i64,
22
+ pub priority: i32,
23
+ pub territory: String,
24
+ }
25
+
26
+ /// Constructor payload for `ServiceVisit`.
27
+ ///
28
+ /// Keeping construction grouped avoids a long positional argument list where a
29
+ /// beginner could easily swap time windows, masks, or location indexes.
30
+ #[derive(Debug, Clone)]
31
+ pub struct ServiceVisitInit {
32
+ pub id: String,
33
+ pub name: String,
34
+ pub customer: String,
35
+ pub location_idx: usize,
36
+ pub duration_minutes: i32,
37
+ pub earliest_minute: i32,
38
+ pub latest_minute: i32,
39
+ pub required_skill_mask: i64,
40
+ pub required_parts_mask: i64,
41
+ pub priority: i32,
42
+ pub territory: String,
43
+ }
44
+
45
+ impl ServiceVisit {
46
+ /// Builds one immutable service-visit fact.
47
+ pub fn new(init: ServiceVisitInit) -> Self {
48
+ Self {
49
+ id: init.id,
50
+ name: init.name,
51
+ customer: init.customer,
52
+ location_idx: init.location_idx,
53
+ duration_minutes: init.duration_minutes,
54
+ earliest_minute: init.earliest_minute,
55
+ latest_minute: init.latest_minute,
56
+ required_skill_mask: init.required_skill_mask,
57
+ required_parts_mask: init.required_parts_mask,
58
+ priority: init.priority,
59
+ territory: init.territory,
60
+ }
61
+ }
62
+ }
63
+
64
+ #[cfg(test)]
65
+ mod tests {
66
+ use super::*;
67
+
68
+ #[test]
69
+ fn test_service_visit_construction() {
70
+ let fact = ServiceVisit::new(ServiceVisitInit {
71
+ id: "test-id".to_string(),
72
+ name: "test".to_string(),
73
+ customer: "test".to_string(),
74
+ location_idx: Default::default(),
75
+ duration_minutes: Default::default(),
76
+ earliest_minute: Default::default(),
77
+ latest_minute: Default::default(),
78
+ required_skill_mask: Default::default(),
79
+ required_parts_mask: Default::default(),
80
+ priority: Default::default(),
81
+ territory: "test".to_string(),
82
+ });
83
+ assert_eq!(fact.id, "test-id");
84
+ assert_eq!(fact.name, "test");
85
+ let _ = &fact.customer;
86
+ let _ = &fact.location_idx;
87
+ let _ = &fact.duration_minutes;
88
+ let _ = &fact.earliest_minute;
89
+ let _ = &fact.latest_minute;
90
+ let _ = &fact.required_skill_mask;
91
+ let _ = &fact.required_parts_mask;
92
+ let _ = &fact.priority;
93
+ let _ = &fact.territory;
94
+ }
95
+ }
src/domain/technician_route.rs ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use solverforge::prelude::*;
3
+
4
+ /// One technician's route, including the visit order SolverForge is allowed to change.
5
+ ///
6
+ /// A `TechnicianRoute` is the planning entity in this app. Its descriptive
7
+ /// fields are fixed input data for the technician, while `visits` is the list
8
+ /// planning variable that local search reorders and moves between routes.
9
+ #[planning_entity]
10
+ #[derive(Serialize, Deserialize)]
11
+ pub struct TechnicianRoute {
12
+ #[planning_id]
13
+ pub id: String,
14
+ pub technician_id: String,
15
+ pub technician_name: String,
16
+ pub color: String,
17
+ pub start_location_idx: usize,
18
+ pub end_location_idx: usize,
19
+ pub shift_start_minute: i32,
20
+ pub shift_end_minute: i32,
21
+ pub max_route_minutes: i32,
22
+ pub skill_mask: i64,
23
+ pub inventory_mask: i64,
24
+ pub territory: String,
25
+ // SolverForge mutates this vector. Each value is an index into
26
+ // `FieldServicePlan.service_visits`, not a copied `ServiceVisit`.
27
+ // @solverforge:begin entity-variables
28
+ #[planning_list_variable(element_collection = "service_visits")]
29
+ pub visits: Vec<usize>,
30
+ // @solverforge:end entity-variables
31
+ }
32
+
33
+ /// Constructor payload for `TechnicianRoute`.
34
+ ///
35
+ /// Grouping the technician attributes keeps call sites readable and makes the
36
+ /// immutable technician data visually separate from the mutable route list.
37
+ #[derive(Debug, Clone)]
38
+ pub struct TechnicianRouteInit {
39
+ pub id: String,
40
+ pub technician_id: String,
41
+ pub technician_name: String,
42
+ pub color: String,
43
+ pub start_location_idx: usize,
44
+ pub end_location_idx: usize,
45
+ pub shift_start_minute: i32,
46
+ pub shift_end_minute: i32,
47
+ pub max_route_minutes: i32,
48
+ pub skill_mask: i64,
49
+ pub inventory_mask: i64,
50
+ pub territory: String,
51
+ }
52
+
53
+ impl TechnicianRoute {
54
+ /// Builds an empty route for one technician.
55
+ ///
56
+ /// The list variable starts empty so construction heuristics can choose the
57
+ /// first assignment instead of inheriting a hand-written visit order.
58
+ pub fn new(init: TechnicianRouteInit) -> Self {
59
+ Self {
60
+ id: init.id,
61
+ technician_id: init.technician_id,
62
+ technician_name: init.technician_name,
63
+ color: init.color,
64
+ start_location_idx: init.start_location_idx,
65
+ end_location_idx: init.end_location_idx,
66
+ shift_start_minute: init.shift_start_minute,
67
+ shift_end_minute: init.shift_end_minute,
68
+ max_route_minutes: init.max_route_minutes,
69
+ skill_mask: init.skill_mask,
70
+ inventory_mask: init.inventory_mask,
71
+ territory: init.territory,
72
+ // @solverforge:begin entity-variable-init
73
+ visits: Vec::new(),
74
+ // @solverforge:end entity-variable-init
75
+ }
76
+ }
77
+ }
78
+
79
+ #[cfg(test)]
80
+ mod tests {
81
+ use super::*;
82
+
83
+ #[test]
84
+ fn test_technician_route_construction() {
85
+ let entity = TechnicianRoute::new(TechnicianRouteInit {
86
+ id: "test-id".to_string(),
87
+ technician_id: "test".to_string(),
88
+ technician_name: "test".to_string(),
89
+ color: "test".to_string(),
90
+ start_location_idx: Default::default(),
91
+ end_location_idx: Default::default(),
92
+ shift_start_minute: Default::default(),
93
+ shift_end_minute: Default::default(),
94
+ max_route_minutes: Default::default(),
95
+ skill_mask: Default::default(),
96
+ inventory_mask: Default::default(),
97
+ territory: "test".to_string(),
98
+ });
99
+ assert_eq!(entity.id, "test-id");
100
+ let _ = &entity.technician_id;
101
+ let _ = &entity.technician_name;
102
+ let _ = &entity.color;
103
+ let _ = &entity.start_location_idx;
104
+ let _ = &entity.end_location_idx;
105
+ let _ = &entity.shift_start_minute;
106
+ let _ = &entity.shift_end_minute;
107
+ let _ = &entity.max_route_minutes;
108
+ let _ = &entity.skill_mask;
109
+ let _ = &entity.inventory_mask;
110
+ let _ = &entity.territory;
111
+ }
112
+ }
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
+ }