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

chore: sync uc-lessons 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. .gitattributes +1 -0
  2. AGENTS.md +79 -0
  3. CHANGELOG.md +19 -0
  4. Cargo.lock +1630 -0
  5. Cargo.toml +29 -0
  6. Dockerfile +38 -0
  7. Makefile +285 -0
  8. README.md +217 -0
  9. WIREFRAME.md +51 -0
  10. docs/screenshot.png +3 -0
  11. solver.toml +17 -0
  12. solverforge.app.toml +119 -0
  13. src/api/dto.rs +295 -0
  14. src/api/mod.rs +11 -0
  15. src/api/routes.rs +220 -0
  16. src/api/sse.rs +74 -0
  17. src/constraints/assign_room.rs +52 -0
  18. src/constraints/assign_timeslot.rs +52 -0
  19. src/constraints/group_availability.rs +77 -0
  20. src/constraints/late_lesson.rs +64 -0
  21. src/constraints/mod.rs +48 -0
  22. src/constraints/no_group_conflict.rs +173 -0
  23. src/constraints/no_room_conflict.rs +186 -0
  24. src/constraints/no_teacher_conflict.rs +193 -0
  25. src/constraints/repeated_subject_day.rs +86 -0
  26. src/constraints/room_capacity.rs +74 -0
  27. src/constraints/room_kind.rs +73 -0
  28. src/constraints/teacher_availability.rs +77 -0
  29. src/data/data_seed.rs +19 -0
  30. src/data/data_seed/entrypoints.rs +121 -0
  31. src/data/data_seed/groups.rs +58 -0
  32. src/data/data_seed/large.rs +43 -0
  33. src/data/data_seed/lessons.rs +172 -0
  34. src/data/data_seed/rooms.rs +26 -0
  35. src/data/data_seed/solve_tests.rs +152 -0
  36. src/data/data_seed/teachers.rs +67 -0
  37. src/data/data_seed/timeslots.rs +208 -0
  38. src/data/data_seed/vocabulary.rs +280 -0
  39. src/data/mod.rs +8 -0
  40. src/domain/group.rs +48 -0
  41. src/domain/lesson.rs +118 -0
  42. src/domain/mod.rs +28 -0
  43. src/domain/plan.rs +249 -0
  44. src/domain/room.rs +61 -0
  45. src/domain/teacher.rs +40 -0
  46. src/domain/timeslot.rs +50 -0
  47. src/domain/weekday.rs +12 -0
  48. src/lib.rs +12 -0
  49. src/main.rs +44 -0
  50. src/solver/event_payload.rs +205 -0
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ docs/screenshot.png filter=lfs diff=lfs merge=lfs -text
AGENTS.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Repository Guidelines
2
+
3
+ ## Project Structure And Naming
4
+
5
+ `solverforge-lessons` is a Rust 1.95 SolverForge lesson-timetabling app with an
6
+ Axum server and static browser workspace. Keep the repo-local directory name
7
+ `uc-lessons`; product copy, metadata, and UI labels should use
8
+ `solverforge-lessons` or SolverForge Lessons.
9
+
10
+ - `src/domain/mod.rs` owns the `solverforge::planning_model!` manifest.
11
+ - `src/domain/plan.rs` owns the `Plan` planning solution.
12
+ - `src/domain/lesson.rs` owns the `Lesson` planning entity and its
13
+ `timeslot_idx` and `room_idx` scalar variables.
14
+ - `src/domain/{timeslot,teacher,group,room}.rs` own the problem facts.
15
+ - `src/constraints/` owns one timetable scoring rule per file plus `mod.rs`
16
+ assembly.
17
+ - `src/data/data_seed/` owns deterministic `LARGE` demo generation.
18
+ - `src/api/` owns REST, DTO, and SSE surfaces.
19
+ - `src/solver/` owns retained-job runtime orchestration.
20
+ - `static/` owns the browser shell and generated view model.
21
+ - `Dockerfile`, `Makefile`, `solver.toml`, and `solverforge.app.toml` define
22
+ the deployment and runtime contract.
23
+
24
+ Keep the canonical solution name `Plan` and the public demo id `LARGE`.
25
+
26
+ ## Build And Validation Commands
27
+
28
+ - `make help` shows the supported command surface.
29
+ - `make run-release` runs the app locally on `:7860`.
30
+ - `make test` runs Rust tests, frontend syntax checks, and the Playwright smoke.
31
+ - `make test-e2e` runs the real browser smoke.
32
+ - `make ci-local` runs formatting, clippy, release build, standard tests, and
33
+ the Space Docker image build.
34
+ - `make test-slow` runs the ignored large-demo acceptance solve.
35
+ - `make pre-release` runs `ci-local` plus the slow acceptance solve.
36
+ - `cargo test` runs Rust unit tests.
37
+ - `PORT=7861 cargo run --bin solverforge-lessons` runs the app on an alternate
38
+ port when `7860` is already occupied.
39
+
40
+ Use the Makefile as the authoritative local workflow.
41
+
42
+ ## Documentation And Commenting Policy
43
+
44
+ Assume a reader who is new to SolverForge and new to optimization modeling.
45
+
46
+ - Keep `README.md`, `WIREFRAME.md`, this file, `solver.toml`,
47
+ `solverforge.app.toml`, `static/sf-config.json`, and `docs/screenshot.png`
48
+ aligned.
49
+ - Add module-level docs or comments for modules that explain their role in the
50
+ app and where they sit in the data flow.
51
+ - Add function comments when the function coordinates SolverForge concepts,
52
+ rebuilds invariants, shapes demo data, converts between layers, or otherwise
53
+ does something a beginner would not infer from the signature.
54
+ - Write comments that explain intent, domain meaning, invariants, and runtime
55
+ consequences. Do not write comments that merely restate syntax.
56
+ - Keep comments present-tense and source-backed. If behavior changes, update or
57
+ delete the stale comment in the same patch.
58
+ - When docs mention versions, counts, routes, demo IDs, solver policy, or
59
+ validation expectations, verify those facts against current code in the same
60
+ patch.
61
+
62
+ The standard to aim for is: a new reader should understand why a piece of code
63
+ exists before they need to understand every line of how it works.
64
+
65
+ ## Testing Guidance
66
+
67
+ Add Rust tests next to the behavior they protect. Real browser flows belong in
68
+ `tests/e2e/`. If you change solver behavior, run `cargo test` and the ignored
69
+ large-demo solve. If you change UI structure, run the frontend syntax check and
70
+ Playwright smoke.
71
+
72
+ ## Runtime Notes
73
+
74
+ `solver.toml` is embedded by `Plan` through the planning-solution macro. Treat
75
+ it as the solver policy source of truth.
76
+
77
+ The app serves stock `solverforge-ui` assets, local static app modules, and
78
+ Axum API routes from one process. Retained solver jobs are controlled through
79
+ REST and observed through SSE.
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
+ ## 0.1.0 (2026-05-14)
12
+
13
+ ### Features
14
+
15
+ * **lessons:** publish the SolverForge lessons scheduling use case in the bundle.
16
+
17
+ ### Maintenance
18
+
19
+ * **release:** align the bundled app with SolverForge 0.13.1 and solverforge-ui 0.6.5.
Cargo.lock ADDED
@@ -0,0 +1,1630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = "android_system_properties"
16
+ version = "0.1.5"
17
+ source = "registry+https://github.com/rust-lang/crates.io-index"
18
+ checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
19
+ dependencies = [
20
+ "libc",
21
+ ]
22
+
23
+ [[package]]
24
+ name = "anyhow"
25
+ version = "1.0.102"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
28
+
29
+ [[package]]
30
+ name = "arrayvec"
31
+ version = "0.7.6"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
34
+
35
+ [[package]]
36
+ name = "atomic-waker"
37
+ version = "1.1.2"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
40
+
41
+ [[package]]
42
+ name = "autocfg"
43
+ version = "1.5.0"
44
+ source = "registry+https://github.com/rust-lang/crates.io-index"
45
+ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
46
+
47
+ [[package]]
48
+ name = "axum"
49
+ version = "0.8.9"
50
+ source = "registry+https://github.com/rust-lang/crates.io-index"
51
+ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
52
+ dependencies = [
53
+ "axum-core",
54
+ "bytes",
55
+ "form_urlencoded",
56
+ "futures-util",
57
+ "http",
58
+ "http-body",
59
+ "http-body-util",
60
+ "hyper",
61
+ "hyper-util",
62
+ "itoa",
63
+ "matchit",
64
+ "memchr",
65
+ "mime",
66
+ "percent-encoding",
67
+ "pin-project-lite",
68
+ "serde_core",
69
+ "serde_json",
70
+ "serde_path_to_error",
71
+ "serde_urlencoded",
72
+ "sync_wrapper",
73
+ "tokio",
74
+ "tower",
75
+ "tower-layer",
76
+ "tower-service",
77
+ "tracing",
78
+ ]
79
+
80
+ [[package]]
81
+ name = "axum-core"
82
+ version = "0.5.6"
83
+ source = "registry+https://github.com/rust-lang/crates.io-index"
84
+ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
85
+ dependencies = [
86
+ "bytes",
87
+ "futures-core",
88
+ "http",
89
+ "http-body",
90
+ "http-body-util",
91
+ "mime",
92
+ "pin-project-lite",
93
+ "sync_wrapper",
94
+ "tower-layer",
95
+ "tower-service",
96
+ "tracing",
97
+ ]
98
+
99
+ [[package]]
100
+ name = "bitflags"
101
+ version = "2.11.1"
102
+ source = "registry+https://github.com/rust-lang/crates.io-index"
103
+ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
104
+
105
+ [[package]]
106
+ name = "bumpalo"
107
+ version = "3.20.2"
108
+ source = "registry+https://github.com/rust-lang/crates.io-index"
109
+ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
110
+
111
+ [[package]]
112
+ name = "bytes"
113
+ version = "1.11.1"
114
+ source = "registry+https://github.com/rust-lang/crates.io-index"
115
+ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
116
+
117
+ [[package]]
118
+ name = "cc"
119
+ version = "1.2.61"
120
+ source = "registry+https://github.com/rust-lang/crates.io-index"
121
+ checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
122
+ dependencies = [
123
+ "find-msvc-tools",
124
+ "shlex",
125
+ ]
126
+
127
+ [[package]]
128
+ name = "cfg-if"
129
+ version = "1.0.4"
130
+ source = "registry+https://github.com/rust-lang/crates.io-index"
131
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
132
+
133
+ [[package]]
134
+ name = "chacha20"
135
+ version = "0.10.0"
136
+ source = "registry+https://github.com/rust-lang/crates.io-index"
137
+ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
138
+ dependencies = [
139
+ "cfg-if",
140
+ "cpufeatures",
141
+ "rand_core",
142
+ ]
143
+
144
+ [[package]]
145
+ name = "chrono"
146
+ version = "0.4.44"
147
+ source = "registry+https://github.com/rust-lang/crates.io-index"
148
+ checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
149
+ dependencies = [
150
+ "iana-time-zone",
151
+ "js-sys",
152
+ "num-traits",
153
+ "serde",
154
+ "wasm-bindgen",
155
+ "windows-link",
156
+ ]
157
+
158
+ [[package]]
159
+ name = "core-foundation-sys"
160
+ version = "0.8.7"
161
+ source = "registry+https://github.com/rust-lang/crates.io-index"
162
+ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
163
+
164
+ [[package]]
165
+ name = "cpufeatures"
166
+ version = "0.3.0"
167
+ source = "registry+https://github.com/rust-lang/crates.io-index"
168
+ checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
169
+ dependencies = [
170
+ "libc",
171
+ ]
172
+
173
+ [[package]]
174
+ name = "crossbeam-deque"
175
+ version = "0.8.6"
176
+ source = "registry+https://github.com/rust-lang/crates.io-index"
177
+ checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
178
+ dependencies = [
179
+ "crossbeam-epoch",
180
+ "crossbeam-utils",
181
+ ]
182
+
183
+ [[package]]
184
+ name = "crossbeam-epoch"
185
+ version = "0.9.18"
186
+ source = "registry+https://github.com/rust-lang/crates.io-index"
187
+ checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
188
+ dependencies = [
189
+ "crossbeam-utils",
190
+ ]
191
+
192
+ [[package]]
193
+ name = "crossbeam-utils"
194
+ version = "0.8.21"
195
+ source = "registry+https://github.com/rust-lang/crates.io-index"
196
+ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
197
+
198
+ [[package]]
199
+ name = "either"
200
+ version = "1.15.0"
201
+ source = "registry+https://github.com/rust-lang/crates.io-index"
202
+ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
203
+
204
+ [[package]]
205
+ name = "equivalent"
206
+ version = "1.0.2"
207
+ source = "registry+https://github.com/rust-lang/crates.io-index"
208
+ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
209
+
210
+ [[package]]
211
+ name = "errno"
212
+ version = "0.3.14"
213
+ source = "registry+https://github.com/rust-lang/crates.io-index"
214
+ checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
215
+ dependencies = [
216
+ "libc",
217
+ "windows-sys",
218
+ ]
219
+
220
+ [[package]]
221
+ name = "find-msvc-tools"
222
+ version = "0.1.9"
223
+ source = "registry+https://github.com/rust-lang/crates.io-index"
224
+ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
225
+
226
+ [[package]]
227
+ name = "foldhash"
228
+ version = "0.1.5"
229
+ source = "registry+https://github.com/rust-lang/crates.io-index"
230
+ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
231
+
232
+ [[package]]
233
+ name = "form_urlencoded"
234
+ version = "1.2.2"
235
+ source = "registry+https://github.com/rust-lang/crates.io-index"
236
+ checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
237
+ dependencies = [
238
+ "percent-encoding",
239
+ ]
240
+
241
+ [[package]]
242
+ name = "futures-channel"
243
+ version = "0.3.32"
244
+ source = "registry+https://github.com/rust-lang/crates.io-index"
245
+ checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
246
+ dependencies = [
247
+ "futures-core",
248
+ ]
249
+
250
+ [[package]]
251
+ name = "futures-core"
252
+ version = "0.3.32"
253
+ source = "registry+https://github.com/rust-lang/crates.io-index"
254
+ checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
255
+
256
+ [[package]]
257
+ name = "futures-sink"
258
+ version = "0.3.32"
259
+ source = "registry+https://github.com/rust-lang/crates.io-index"
260
+ checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
261
+
262
+ [[package]]
263
+ name = "futures-task"
264
+ version = "0.3.32"
265
+ source = "registry+https://github.com/rust-lang/crates.io-index"
266
+ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
267
+
268
+ [[package]]
269
+ name = "futures-util"
270
+ version = "0.3.32"
271
+ source = "registry+https://github.com/rust-lang/crates.io-index"
272
+ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
273
+ dependencies = [
274
+ "futures-core",
275
+ "futures-task",
276
+ "pin-project-lite",
277
+ "slab",
278
+ ]
279
+
280
+ [[package]]
281
+ name = "getrandom"
282
+ version = "0.4.2"
283
+ source = "registry+https://github.com/rust-lang/crates.io-index"
284
+ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
285
+ dependencies = [
286
+ "cfg-if",
287
+ "libc",
288
+ "r-efi",
289
+ "rand_core",
290
+ "wasip2",
291
+ "wasip3",
292
+ ]
293
+
294
+ [[package]]
295
+ name = "hashbrown"
296
+ version = "0.15.5"
297
+ source = "registry+https://github.com/rust-lang/crates.io-index"
298
+ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
299
+ dependencies = [
300
+ "foldhash",
301
+ ]
302
+
303
+ [[package]]
304
+ name = "hashbrown"
305
+ version = "0.17.0"
306
+ source = "registry+https://github.com/rust-lang/crates.io-index"
307
+ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
308
+
309
+ [[package]]
310
+ name = "heck"
311
+ version = "0.5.0"
312
+ source = "registry+https://github.com/rust-lang/crates.io-index"
313
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
314
+
315
+ [[package]]
316
+ name = "http"
317
+ version = "1.4.0"
318
+ source = "registry+https://github.com/rust-lang/crates.io-index"
319
+ checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
320
+ dependencies = [
321
+ "bytes",
322
+ "itoa",
323
+ ]
324
+
325
+ [[package]]
326
+ name = "http-body"
327
+ version = "1.0.1"
328
+ source = "registry+https://github.com/rust-lang/crates.io-index"
329
+ checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
330
+ dependencies = [
331
+ "bytes",
332
+ "http",
333
+ ]
334
+
335
+ [[package]]
336
+ name = "http-body-util"
337
+ version = "0.1.3"
338
+ source = "registry+https://github.com/rust-lang/crates.io-index"
339
+ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
340
+ dependencies = [
341
+ "bytes",
342
+ "futures-core",
343
+ "http",
344
+ "http-body",
345
+ "pin-project-lite",
346
+ ]
347
+
348
+ [[package]]
349
+ name = "http-range-header"
350
+ version = "0.4.2"
351
+ source = "registry+https://github.com/rust-lang/crates.io-index"
352
+ checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
353
+
354
+ [[package]]
355
+ name = "httparse"
356
+ version = "1.10.1"
357
+ source = "registry+https://github.com/rust-lang/crates.io-index"
358
+ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
359
+
360
+ [[package]]
361
+ name = "httpdate"
362
+ version = "1.0.3"
363
+ source = "registry+https://github.com/rust-lang/crates.io-index"
364
+ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
365
+
366
+ [[package]]
367
+ name = "hyper"
368
+ version = "1.9.0"
369
+ source = "registry+https://github.com/rust-lang/crates.io-index"
370
+ checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
371
+ dependencies = [
372
+ "atomic-waker",
373
+ "bytes",
374
+ "futures-channel",
375
+ "futures-core",
376
+ "http",
377
+ "http-body",
378
+ "httparse",
379
+ "httpdate",
380
+ "itoa",
381
+ "pin-project-lite",
382
+ "smallvec",
383
+ "tokio",
384
+ ]
385
+
386
+ [[package]]
387
+ name = "hyper-util"
388
+ version = "0.1.20"
389
+ source = "registry+https://github.com/rust-lang/crates.io-index"
390
+ checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
391
+ dependencies = [
392
+ "bytes",
393
+ "http",
394
+ "http-body",
395
+ "hyper",
396
+ "pin-project-lite",
397
+ "tokio",
398
+ "tower-service",
399
+ ]
400
+
401
+ [[package]]
402
+ name = "iana-time-zone"
403
+ version = "0.1.65"
404
+ source = "registry+https://github.com/rust-lang/crates.io-index"
405
+ checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
406
+ dependencies = [
407
+ "android_system_properties",
408
+ "core-foundation-sys",
409
+ "iana-time-zone-haiku",
410
+ "js-sys",
411
+ "log",
412
+ "wasm-bindgen",
413
+ "windows-core",
414
+ ]
415
+
416
+ [[package]]
417
+ name = "iana-time-zone-haiku"
418
+ version = "0.1.2"
419
+ source = "registry+https://github.com/rust-lang/crates.io-index"
420
+ checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
421
+ dependencies = [
422
+ "cc",
423
+ ]
424
+
425
+ [[package]]
426
+ name = "id-arena"
427
+ version = "2.3.0"
428
+ source = "registry+https://github.com/rust-lang/crates.io-index"
429
+ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
430
+
431
+ [[package]]
432
+ name = "include_dir"
433
+ version = "0.7.4"
434
+ source = "registry+https://github.com/rust-lang/crates.io-index"
435
+ checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
436
+ dependencies = [
437
+ "include_dir_macros",
438
+ ]
439
+
440
+ [[package]]
441
+ name = "include_dir_macros"
442
+ version = "0.7.4"
443
+ source = "registry+https://github.com/rust-lang/crates.io-index"
444
+ checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
445
+ dependencies = [
446
+ "proc-macro2",
447
+ "quote",
448
+ ]
449
+
450
+ [[package]]
451
+ name = "indexmap"
452
+ version = "2.14.0"
453
+ source = "registry+https://github.com/rust-lang/crates.io-index"
454
+ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
455
+ dependencies = [
456
+ "equivalent",
457
+ "hashbrown 0.17.0",
458
+ "serde",
459
+ "serde_core",
460
+ ]
461
+
462
+ [[package]]
463
+ name = "itoa"
464
+ version = "1.0.18"
465
+ source = "registry+https://github.com/rust-lang/crates.io-index"
466
+ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
467
+
468
+ [[package]]
469
+ name = "js-sys"
470
+ version = "0.3.97"
471
+ source = "registry+https://github.com/rust-lang/crates.io-index"
472
+ checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
473
+ dependencies = [
474
+ "cfg-if",
475
+ "futures-util",
476
+ "once_cell",
477
+ "wasm-bindgen",
478
+ ]
479
+
480
+ [[package]]
481
+ name = "lazy_static"
482
+ version = "1.5.0"
483
+ source = "registry+https://github.com/rust-lang/crates.io-index"
484
+ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
485
+
486
+ [[package]]
487
+ name = "leb128fmt"
488
+ version = "0.1.0"
489
+ source = "registry+https://github.com/rust-lang/crates.io-index"
490
+ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
491
+
492
+ [[package]]
493
+ name = "libc"
494
+ version = "0.2.186"
495
+ source = "registry+https://github.com/rust-lang/crates.io-index"
496
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
497
+
498
+ [[package]]
499
+ name = "lock_api"
500
+ version = "0.4.14"
501
+ source = "registry+https://github.com/rust-lang/crates.io-index"
502
+ checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
503
+ dependencies = [
504
+ "scopeguard",
505
+ ]
506
+
507
+ [[package]]
508
+ name = "log"
509
+ version = "0.4.29"
510
+ source = "registry+https://github.com/rust-lang/crates.io-index"
511
+ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
512
+
513
+ [[package]]
514
+ name = "matchers"
515
+ version = "0.2.0"
516
+ source = "registry+https://github.com/rust-lang/crates.io-index"
517
+ checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
518
+ dependencies = [
519
+ "regex-automata",
520
+ ]
521
+
522
+ [[package]]
523
+ name = "matchit"
524
+ version = "0.8.4"
525
+ source = "registry+https://github.com/rust-lang/crates.io-index"
526
+ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
527
+
528
+ [[package]]
529
+ name = "memchr"
530
+ version = "2.8.0"
531
+ source = "registry+https://github.com/rust-lang/crates.io-index"
532
+ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
533
+
534
+ [[package]]
535
+ name = "mime"
536
+ version = "0.3.17"
537
+ source = "registry+https://github.com/rust-lang/crates.io-index"
538
+ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
539
+
540
+ [[package]]
541
+ name = "mime_guess"
542
+ version = "2.0.5"
543
+ source = "registry+https://github.com/rust-lang/crates.io-index"
544
+ checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
545
+ dependencies = [
546
+ "mime",
547
+ "unicase",
548
+ ]
549
+
550
+ [[package]]
551
+ name = "mio"
552
+ version = "1.2.0"
553
+ source = "registry+https://github.com/rust-lang/crates.io-index"
554
+ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
555
+ dependencies = [
556
+ "libc",
557
+ "wasi",
558
+ "windows-sys",
559
+ ]
560
+
561
+ [[package]]
562
+ name = "nu-ansi-term"
563
+ version = "0.50.3"
564
+ source = "registry+https://github.com/rust-lang/crates.io-index"
565
+ checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
566
+ dependencies = [
567
+ "windows-sys",
568
+ ]
569
+
570
+ [[package]]
571
+ name = "num-format"
572
+ version = "0.4.4"
573
+ source = "registry+https://github.com/rust-lang/crates.io-index"
574
+ checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
575
+ dependencies = [
576
+ "arrayvec",
577
+ "itoa",
578
+ ]
579
+
580
+ [[package]]
581
+ name = "num-traits"
582
+ version = "0.2.19"
583
+ source = "registry+https://github.com/rust-lang/crates.io-index"
584
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
585
+ dependencies = [
586
+ "autocfg",
587
+ ]
588
+
589
+ [[package]]
590
+ name = "once_cell"
591
+ version = "1.21.4"
592
+ source = "registry+https://github.com/rust-lang/crates.io-index"
593
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
594
+
595
+ [[package]]
596
+ name = "owo-colors"
597
+ version = "4.3.0"
598
+ source = "registry+https://github.com/rust-lang/crates.io-index"
599
+ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
600
+
601
+ [[package]]
602
+ name = "parking_lot"
603
+ version = "0.12.5"
604
+ source = "registry+https://github.com/rust-lang/crates.io-index"
605
+ checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
606
+ dependencies = [
607
+ "lock_api",
608
+ "parking_lot_core",
609
+ ]
610
+
611
+ [[package]]
612
+ name = "parking_lot_core"
613
+ version = "0.9.12"
614
+ source = "registry+https://github.com/rust-lang/crates.io-index"
615
+ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
616
+ dependencies = [
617
+ "cfg-if",
618
+ "libc",
619
+ "redox_syscall",
620
+ "smallvec",
621
+ "windows-link",
622
+ ]
623
+
624
+ [[package]]
625
+ name = "percent-encoding"
626
+ version = "2.3.2"
627
+ source = "registry+https://github.com/rust-lang/crates.io-index"
628
+ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
629
+
630
+ [[package]]
631
+ name = "pin-project-lite"
632
+ version = "0.2.17"
633
+ source = "registry+https://github.com/rust-lang/crates.io-index"
634
+ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
635
+
636
+ [[package]]
637
+ name = "ppv-lite86"
638
+ version = "0.2.21"
639
+ source = "registry+https://github.com/rust-lang/crates.io-index"
640
+ checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
641
+ dependencies = [
642
+ "zerocopy",
643
+ ]
644
+
645
+ [[package]]
646
+ name = "prettyplease"
647
+ version = "0.2.37"
648
+ source = "registry+https://github.com/rust-lang/crates.io-index"
649
+ checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
650
+ dependencies = [
651
+ "proc-macro2",
652
+ "syn",
653
+ ]
654
+
655
+ [[package]]
656
+ name = "proc-macro2"
657
+ version = "1.0.106"
658
+ source = "registry+https://github.com/rust-lang/crates.io-index"
659
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
660
+ dependencies = [
661
+ "unicode-ident",
662
+ ]
663
+
664
+ [[package]]
665
+ name = "quote"
666
+ version = "1.0.45"
667
+ source = "registry+https://github.com/rust-lang/crates.io-index"
668
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
669
+ dependencies = [
670
+ "proc-macro2",
671
+ ]
672
+
673
+ [[package]]
674
+ name = "r-efi"
675
+ version = "6.0.0"
676
+ source = "registry+https://github.com/rust-lang/crates.io-index"
677
+ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
678
+
679
+ [[package]]
680
+ name = "rand"
681
+ version = "0.10.1"
682
+ source = "registry+https://github.com/rust-lang/crates.io-index"
683
+ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
684
+ dependencies = [
685
+ "chacha20",
686
+ "getrandom",
687
+ "rand_core",
688
+ ]
689
+
690
+ [[package]]
691
+ name = "rand_chacha"
692
+ version = "0.10.0"
693
+ source = "registry+https://github.com/rust-lang/crates.io-index"
694
+ checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb"
695
+ dependencies = [
696
+ "ppv-lite86",
697
+ "rand_core",
698
+ ]
699
+
700
+ [[package]]
701
+ name = "rand_core"
702
+ version = "0.10.1"
703
+ source = "registry+https://github.com/rust-lang/crates.io-index"
704
+ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
705
+
706
+ [[package]]
707
+ name = "rayon"
708
+ version = "1.12.0"
709
+ source = "registry+https://github.com/rust-lang/crates.io-index"
710
+ checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
711
+ dependencies = [
712
+ "either",
713
+ "rayon-core",
714
+ ]
715
+
716
+ [[package]]
717
+ name = "rayon-core"
718
+ version = "1.13.0"
719
+ source = "registry+https://github.com/rust-lang/crates.io-index"
720
+ checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
721
+ dependencies = [
722
+ "crossbeam-deque",
723
+ "crossbeam-utils",
724
+ ]
725
+
726
+ [[package]]
727
+ name = "redox_syscall"
728
+ version = "0.5.18"
729
+ source = "registry+https://github.com/rust-lang/crates.io-index"
730
+ checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
731
+ dependencies = [
732
+ "bitflags",
733
+ ]
734
+
735
+ [[package]]
736
+ name = "regex-automata"
737
+ version = "0.4.14"
738
+ source = "registry+https://github.com/rust-lang/crates.io-index"
739
+ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
740
+ dependencies = [
741
+ "aho-corasick",
742
+ "memchr",
743
+ "regex-syntax",
744
+ ]
745
+
746
+ [[package]]
747
+ name = "regex-syntax"
748
+ version = "0.8.10"
749
+ source = "registry+https://github.com/rust-lang/crates.io-index"
750
+ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
751
+
752
+ [[package]]
753
+ name = "rustversion"
754
+ version = "1.0.22"
755
+ source = "registry+https://github.com/rust-lang/crates.io-index"
756
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
757
+
758
+ [[package]]
759
+ name = "ryu"
760
+ version = "1.0.23"
761
+ source = "registry+https://github.com/rust-lang/crates.io-index"
762
+ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
763
+
764
+ [[package]]
765
+ name = "scopeguard"
766
+ version = "1.2.0"
767
+ source = "registry+https://github.com/rust-lang/crates.io-index"
768
+ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
769
+
770
+ [[package]]
771
+ name = "semver"
772
+ version = "1.0.28"
773
+ source = "registry+https://github.com/rust-lang/crates.io-index"
774
+ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
775
+
776
+ [[package]]
777
+ name = "serde"
778
+ version = "1.0.228"
779
+ source = "registry+https://github.com/rust-lang/crates.io-index"
780
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
781
+ dependencies = [
782
+ "serde_core",
783
+ "serde_derive",
784
+ ]
785
+
786
+ [[package]]
787
+ name = "serde_core"
788
+ version = "1.0.228"
789
+ source = "registry+https://github.com/rust-lang/crates.io-index"
790
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
791
+ dependencies = [
792
+ "serde_derive",
793
+ ]
794
+
795
+ [[package]]
796
+ name = "serde_derive"
797
+ version = "1.0.228"
798
+ source = "registry+https://github.com/rust-lang/crates.io-index"
799
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
800
+ dependencies = [
801
+ "proc-macro2",
802
+ "quote",
803
+ "syn",
804
+ ]
805
+
806
+ [[package]]
807
+ name = "serde_json"
808
+ version = "1.0.149"
809
+ source = "registry+https://github.com/rust-lang/crates.io-index"
810
+ checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
811
+ dependencies = [
812
+ "itoa",
813
+ "memchr",
814
+ "serde",
815
+ "serde_core",
816
+ "zmij",
817
+ ]
818
+
819
+ [[package]]
820
+ name = "serde_path_to_error"
821
+ version = "0.1.20"
822
+ source = "registry+https://github.com/rust-lang/crates.io-index"
823
+ checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
824
+ dependencies = [
825
+ "itoa",
826
+ "serde",
827
+ "serde_core",
828
+ ]
829
+
830
+ [[package]]
831
+ name = "serde_spanned"
832
+ version = "1.1.1"
833
+ source = "registry+https://github.com/rust-lang/crates.io-index"
834
+ checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
835
+ dependencies = [
836
+ "serde_core",
837
+ ]
838
+
839
+ [[package]]
840
+ name = "serde_urlencoded"
841
+ version = "0.7.1"
842
+ source = "registry+https://github.com/rust-lang/crates.io-index"
843
+ checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
844
+ dependencies = [
845
+ "form_urlencoded",
846
+ "itoa",
847
+ "ryu",
848
+ "serde",
849
+ ]
850
+
851
+ [[package]]
852
+ name = "serde_yaml"
853
+ version = "0.9.34+deprecated"
854
+ source = "registry+https://github.com/rust-lang/crates.io-index"
855
+ checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
856
+ dependencies = [
857
+ "indexmap",
858
+ "itoa",
859
+ "ryu",
860
+ "serde",
861
+ "unsafe-libyaml",
862
+ ]
863
+
864
+ [[package]]
865
+ name = "sharded-slab"
866
+ version = "0.1.7"
867
+ source = "registry+https://github.com/rust-lang/crates.io-index"
868
+ checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
869
+ dependencies = [
870
+ "lazy_static",
871
+ ]
872
+
873
+ [[package]]
874
+ name = "shlex"
875
+ version = "1.3.0"
876
+ source = "registry+https://github.com/rust-lang/crates.io-index"
877
+ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
878
+
879
+ [[package]]
880
+ name = "signal-hook-registry"
881
+ version = "1.4.8"
882
+ source = "registry+https://github.com/rust-lang/crates.io-index"
883
+ checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
884
+ dependencies = [
885
+ "errno",
886
+ "libc",
887
+ ]
888
+
889
+ [[package]]
890
+ name = "slab"
891
+ version = "0.4.12"
892
+ source = "registry+https://github.com/rust-lang/crates.io-index"
893
+ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
894
+
895
+ [[package]]
896
+ name = "smallvec"
897
+ version = "1.15.1"
898
+ source = "registry+https://github.com/rust-lang/crates.io-index"
899
+ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
900
+
901
+ [[package]]
902
+ name = "socket2"
903
+ version = "0.6.3"
904
+ source = "registry+https://github.com/rust-lang/crates.io-index"
905
+ checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
906
+ dependencies = [
907
+ "libc",
908
+ "windows-sys",
909
+ ]
910
+
911
+ [[package]]
912
+ name = "solverforge"
913
+ version = "0.13.1"
914
+ source = "registry+https://github.com/rust-lang/crates.io-index"
915
+ checksum = "d86fef307d129ed9d674f29e2f37be912a9b901a4fe9f43348f1044879ef7094"
916
+ dependencies = [
917
+ "solverforge-config",
918
+ "solverforge-console",
919
+ "solverforge-core",
920
+ "solverforge-cvrp",
921
+ "solverforge-macros",
922
+ "solverforge-scoring",
923
+ "solverforge-solver",
924
+ "tokio",
925
+ ]
926
+
927
+ [[package]]
928
+ name = "solverforge-config"
929
+ version = "0.13.1"
930
+ source = "registry+https://github.com/rust-lang/crates.io-index"
931
+ checksum = "434a6c8f9f308d20c7d364324de9d0dbb27d5fe03a7e8f1cead0c2574ebe947e"
932
+ dependencies = [
933
+ "serde",
934
+ "serde_yaml",
935
+ "solverforge-core",
936
+ "thiserror",
937
+ "toml",
938
+ ]
939
+
940
+ [[package]]
941
+ name = "solverforge-console"
942
+ version = "0.13.1"
943
+ source = "registry+https://github.com/rust-lang/crates.io-index"
944
+ checksum = "7456459eb2efbb3d57a6b5234c7d29a2aab84c8d14d5a09807ab0ac94bf9a786"
945
+ dependencies = [
946
+ "num-format",
947
+ "owo-colors",
948
+ "tracing",
949
+ "tracing-subscriber",
950
+ ]
951
+
952
+ [[package]]
953
+ name = "solverforge-core"
954
+ version = "0.13.1"
955
+ source = "registry+https://github.com/rust-lang/crates.io-index"
956
+ checksum = "ccc29944f60ac425f53b4605ea2f0fa5fa499549139df7a889aa5fdc47bcc066"
957
+ dependencies = [
958
+ "serde",
959
+ "thiserror",
960
+ ]
961
+
962
+ [[package]]
963
+ name = "solverforge-cvrp"
964
+ version = "0.13.1"
965
+ source = "registry+https://github.com/rust-lang/crates.io-index"
966
+ checksum = "91cbe85229dd46e845f95f1f4d2ac993145680b02e044acdce220ee4ddecbd36"
967
+ dependencies = [
968
+ "solverforge-solver",
969
+ ]
970
+
971
+ [[package]]
972
+ name = "solverforge-lessons"
973
+ version = "2.0.0"
974
+ dependencies = [
975
+ "axum",
976
+ "chrono",
977
+ "parking_lot",
978
+ "serde",
979
+ "serde_json",
980
+ "solverforge",
981
+ "solverforge-ui",
982
+ "tokio",
983
+ "tokio-stream",
984
+ "tower",
985
+ "tower-http",
986
+ "uuid",
987
+ ]
988
+
989
+ [[package]]
990
+ name = "solverforge-macros"
991
+ version = "0.13.1"
992
+ source = "registry+https://github.com/rust-lang/crates.io-index"
993
+ checksum = "2f830c19a7d9c7fe10911cf597b0b98c6d63ef2ec70330ac6e0b2746e8db8d98"
994
+ dependencies = [
995
+ "proc-macro2",
996
+ "quote",
997
+ "syn",
998
+ ]
999
+
1000
+ [[package]]
1001
+ name = "solverforge-scoring"
1002
+ version = "0.13.1"
1003
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1004
+ checksum = "1efaede461a62baa20a0f65887bda5760158a125a831ecc73c50d713ac9d6ce7"
1005
+ dependencies = [
1006
+ "solverforge-core",
1007
+ "thiserror",
1008
+ ]
1009
+
1010
+ [[package]]
1011
+ name = "solverforge-solver"
1012
+ version = "0.13.1"
1013
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1014
+ checksum = "0156969a9e5d965d7fda36a8581646115e5594a2d50d1036e101dcb001167530"
1015
+ dependencies = [
1016
+ "rand",
1017
+ "rand_chacha",
1018
+ "rayon",
1019
+ "serde",
1020
+ "smallvec",
1021
+ "solverforge-config",
1022
+ "solverforge-core",
1023
+ "solverforge-scoring",
1024
+ "thiserror",
1025
+ "tokio",
1026
+ "tracing",
1027
+ ]
1028
+
1029
+ [[package]]
1030
+ name = "solverforge-ui"
1031
+ version = "0.6.5"
1032
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1033
+ checksum = "1c7fa2d78c84af9a1e264adcffc1bdf8cb4edab8d73a3543fb448d166c95596f"
1034
+ dependencies = [
1035
+ "axum",
1036
+ "include_dir",
1037
+ ]
1038
+
1039
+ [[package]]
1040
+ name = "syn"
1041
+ version = "2.0.117"
1042
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1043
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
1044
+ dependencies = [
1045
+ "proc-macro2",
1046
+ "quote",
1047
+ "unicode-ident",
1048
+ ]
1049
+
1050
+ [[package]]
1051
+ name = "sync_wrapper"
1052
+ version = "1.0.2"
1053
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1054
+ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1055
+
1056
+ [[package]]
1057
+ name = "thiserror"
1058
+ version = "2.0.18"
1059
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1060
+ checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1061
+ dependencies = [
1062
+ "thiserror-impl",
1063
+ ]
1064
+
1065
+ [[package]]
1066
+ name = "thiserror-impl"
1067
+ version = "2.0.18"
1068
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1069
+ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1070
+ dependencies = [
1071
+ "proc-macro2",
1072
+ "quote",
1073
+ "syn",
1074
+ ]
1075
+
1076
+ [[package]]
1077
+ name = "thread_local"
1078
+ version = "1.1.9"
1079
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1080
+ checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
1081
+ dependencies = [
1082
+ "cfg-if",
1083
+ ]
1084
+
1085
+ [[package]]
1086
+ name = "tokio"
1087
+ version = "1.52.2"
1088
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1089
+ checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"
1090
+ dependencies = [
1091
+ "bytes",
1092
+ "libc",
1093
+ "mio",
1094
+ "parking_lot",
1095
+ "pin-project-lite",
1096
+ "signal-hook-registry",
1097
+ "socket2",
1098
+ "tokio-macros",
1099
+ "windows-sys",
1100
+ ]
1101
+
1102
+ [[package]]
1103
+ name = "tokio-macros"
1104
+ version = "2.7.0"
1105
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1106
+ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
1107
+ dependencies = [
1108
+ "proc-macro2",
1109
+ "quote",
1110
+ "syn",
1111
+ ]
1112
+
1113
+ [[package]]
1114
+ name = "tokio-stream"
1115
+ version = "0.1.18"
1116
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1117
+ checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
1118
+ dependencies = [
1119
+ "futures-core",
1120
+ "pin-project-lite",
1121
+ "tokio",
1122
+ "tokio-util",
1123
+ ]
1124
+
1125
+ [[package]]
1126
+ name = "tokio-util"
1127
+ version = "0.7.18"
1128
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1129
+ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
1130
+ dependencies = [
1131
+ "bytes",
1132
+ "futures-core",
1133
+ "futures-sink",
1134
+ "pin-project-lite",
1135
+ "tokio",
1136
+ ]
1137
+
1138
+ [[package]]
1139
+ name = "toml"
1140
+ version = "1.1.2+spec-1.1.0"
1141
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1142
+ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
1143
+ dependencies = [
1144
+ "indexmap",
1145
+ "serde_core",
1146
+ "serde_spanned",
1147
+ "toml_datetime",
1148
+ "toml_parser",
1149
+ "toml_writer",
1150
+ "winnow",
1151
+ ]
1152
+
1153
+ [[package]]
1154
+ name = "toml_datetime"
1155
+ version = "1.1.1+spec-1.1.0"
1156
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1157
+ checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
1158
+ dependencies = [
1159
+ "serde_core",
1160
+ ]
1161
+
1162
+ [[package]]
1163
+ name = "toml_parser"
1164
+ version = "1.1.2+spec-1.1.0"
1165
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1166
+ checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
1167
+ dependencies = [
1168
+ "winnow",
1169
+ ]
1170
+
1171
+ [[package]]
1172
+ name = "toml_writer"
1173
+ version = "1.1.1+spec-1.1.0"
1174
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1175
+ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
1176
+
1177
+ [[package]]
1178
+ name = "tower"
1179
+ version = "0.5.3"
1180
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1181
+ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
1182
+ dependencies = [
1183
+ "futures-core",
1184
+ "futures-util",
1185
+ "pin-project-lite",
1186
+ "sync_wrapper",
1187
+ "tokio",
1188
+ "tower-layer",
1189
+ "tower-service",
1190
+ "tracing",
1191
+ ]
1192
+
1193
+ [[package]]
1194
+ name = "tower-http"
1195
+ version = "0.6.8"
1196
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1197
+ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
1198
+ dependencies = [
1199
+ "bitflags",
1200
+ "bytes",
1201
+ "futures-core",
1202
+ "futures-util",
1203
+ "http",
1204
+ "http-body",
1205
+ "http-body-util",
1206
+ "http-range-header",
1207
+ "httpdate",
1208
+ "mime",
1209
+ "mime_guess",
1210
+ "percent-encoding",
1211
+ "pin-project-lite",
1212
+ "tokio",
1213
+ "tokio-util",
1214
+ "tower-layer",
1215
+ "tower-service",
1216
+ "tracing",
1217
+ ]
1218
+
1219
+ [[package]]
1220
+ name = "tower-layer"
1221
+ version = "0.3.3"
1222
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1223
+ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
1224
+
1225
+ [[package]]
1226
+ name = "tower-service"
1227
+ version = "0.3.3"
1228
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1229
+ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
1230
+
1231
+ [[package]]
1232
+ name = "tracing"
1233
+ version = "0.1.44"
1234
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1235
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1236
+ dependencies = [
1237
+ "log",
1238
+ "pin-project-lite",
1239
+ "tracing-attributes",
1240
+ "tracing-core",
1241
+ ]
1242
+
1243
+ [[package]]
1244
+ name = "tracing-attributes"
1245
+ version = "0.1.31"
1246
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1247
+ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1248
+ dependencies = [
1249
+ "proc-macro2",
1250
+ "quote",
1251
+ "syn",
1252
+ ]
1253
+
1254
+ [[package]]
1255
+ name = "tracing-core"
1256
+ version = "0.1.36"
1257
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1258
+ checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1259
+ dependencies = [
1260
+ "once_cell",
1261
+ "valuable",
1262
+ ]
1263
+
1264
+ [[package]]
1265
+ name = "tracing-log"
1266
+ version = "0.2.0"
1267
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1268
+ checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1269
+ dependencies = [
1270
+ "log",
1271
+ "once_cell",
1272
+ "tracing-core",
1273
+ ]
1274
+
1275
+ [[package]]
1276
+ name = "tracing-subscriber"
1277
+ version = "0.3.23"
1278
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1279
+ checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
1280
+ dependencies = [
1281
+ "matchers",
1282
+ "nu-ansi-term",
1283
+ "once_cell",
1284
+ "regex-automata",
1285
+ "sharded-slab",
1286
+ "smallvec",
1287
+ "thread_local",
1288
+ "tracing",
1289
+ "tracing-core",
1290
+ "tracing-log",
1291
+ ]
1292
+
1293
+ [[package]]
1294
+ name = "unicase"
1295
+ version = "2.9.0"
1296
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1297
+ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
1298
+
1299
+ [[package]]
1300
+ name = "unicode-ident"
1301
+ version = "1.0.24"
1302
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1303
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1304
+
1305
+ [[package]]
1306
+ name = "unicode-xid"
1307
+ version = "0.2.6"
1308
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1309
+ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
1310
+
1311
+ [[package]]
1312
+ name = "unsafe-libyaml"
1313
+ version = "0.2.11"
1314
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1315
+ checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
1316
+
1317
+ [[package]]
1318
+ name = "uuid"
1319
+ version = "1.23.1"
1320
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1321
+ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
1322
+ dependencies = [
1323
+ "getrandom",
1324
+ "js-sys",
1325
+ "serde_core",
1326
+ "wasm-bindgen",
1327
+ ]
1328
+
1329
+ [[package]]
1330
+ name = "valuable"
1331
+ version = "0.1.1"
1332
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1333
+ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1334
+
1335
+ [[package]]
1336
+ name = "wasi"
1337
+ version = "0.11.1+wasi-snapshot-preview1"
1338
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1339
+ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1340
+
1341
+ [[package]]
1342
+ name = "wasip2"
1343
+ version = "1.0.3+wasi-0.2.9"
1344
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1345
+ checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
1346
+ dependencies = [
1347
+ "wit-bindgen 0.57.1",
1348
+ ]
1349
+
1350
+ [[package]]
1351
+ name = "wasip3"
1352
+ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
1353
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1354
+ checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
1355
+ dependencies = [
1356
+ "wit-bindgen 0.51.0",
1357
+ ]
1358
+
1359
+ [[package]]
1360
+ name = "wasm-bindgen"
1361
+ version = "0.2.120"
1362
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1363
+ checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
1364
+ dependencies = [
1365
+ "cfg-if",
1366
+ "once_cell",
1367
+ "rustversion",
1368
+ "wasm-bindgen-macro",
1369
+ "wasm-bindgen-shared",
1370
+ ]
1371
+
1372
+ [[package]]
1373
+ name = "wasm-bindgen-macro"
1374
+ version = "0.2.120"
1375
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1376
+ checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
1377
+ dependencies = [
1378
+ "quote",
1379
+ "wasm-bindgen-macro-support",
1380
+ ]
1381
+
1382
+ [[package]]
1383
+ name = "wasm-bindgen-macro-support"
1384
+ version = "0.2.120"
1385
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1386
+ checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
1387
+ dependencies = [
1388
+ "bumpalo",
1389
+ "proc-macro2",
1390
+ "quote",
1391
+ "syn",
1392
+ "wasm-bindgen-shared",
1393
+ ]
1394
+
1395
+ [[package]]
1396
+ name = "wasm-bindgen-shared"
1397
+ version = "0.2.120"
1398
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1399
+ checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
1400
+ dependencies = [
1401
+ "unicode-ident",
1402
+ ]
1403
+
1404
+ [[package]]
1405
+ name = "wasm-encoder"
1406
+ version = "0.244.0"
1407
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1408
+ checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
1409
+ dependencies = [
1410
+ "leb128fmt",
1411
+ "wasmparser",
1412
+ ]
1413
+
1414
+ [[package]]
1415
+ name = "wasm-metadata"
1416
+ version = "0.244.0"
1417
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1418
+ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
1419
+ dependencies = [
1420
+ "anyhow",
1421
+ "indexmap",
1422
+ "wasm-encoder",
1423
+ "wasmparser",
1424
+ ]
1425
+
1426
+ [[package]]
1427
+ name = "wasmparser"
1428
+ version = "0.244.0"
1429
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1430
+ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
1431
+ dependencies = [
1432
+ "bitflags",
1433
+ "hashbrown 0.15.5",
1434
+ "indexmap",
1435
+ "semver",
1436
+ ]
1437
+
1438
+ [[package]]
1439
+ name = "windows-core"
1440
+ version = "0.62.2"
1441
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1442
+ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
1443
+ dependencies = [
1444
+ "windows-implement",
1445
+ "windows-interface",
1446
+ "windows-link",
1447
+ "windows-result",
1448
+ "windows-strings",
1449
+ ]
1450
+
1451
+ [[package]]
1452
+ name = "windows-implement"
1453
+ version = "0.60.2"
1454
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1455
+ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
1456
+ dependencies = [
1457
+ "proc-macro2",
1458
+ "quote",
1459
+ "syn",
1460
+ ]
1461
+
1462
+ [[package]]
1463
+ name = "windows-interface"
1464
+ version = "0.59.3"
1465
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1466
+ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
1467
+ dependencies = [
1468
+ "proc-macro2",
1469
+ "quote",
1470
+ "syn",
1471
+ ]
1472
+
1473
+ [[package]]
1474
+ name = "windows-link"
1475
+ version = "0.2.1"
1476
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1477
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1478
+
1479
+ [[package]]
1480
+ name = "windows-result"
1481
+ version = "0.4.1"
1482
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1483
+ checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
1484
+ dependencies = [
1485
+ "windows-link",
1486
+ ]
1487
+
1488
+ [[package]]
1489
+ name = "windows-strings"
1490
+ version = "0.5.1"
1491
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1492
+ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
1493
+ dependencies = [
1494
+ "windows-link",
1495
+ ]
1496
+
1497
+ [[package]]
1498
+ name = "windows-sys"
1499
+ version = "0.61.2"
1500
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1501
+ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1502
+ dependencies = [
1503
+ "windows-link",
1504
+ ]
1505
+
1506
+ [[package]]
1507
+ name = "winnow"
1508
+ version = "1.0.2"
1509
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1510
+ checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
1511
+
1512
+ [[package]]
1513
+ name = "wit-bindgen"
1514
+ version = "0.51.0"
1515
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1516
+ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
1517
+ dependencies = [
1518
+ "wit-bindgen-rust-macro",
1519
+ ]
1520
+
1521
+ [[package]]
1522
+ name = "wit-bindgen"
1523
+ version = "0.57.1"
1524
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1525
+ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
1526
+
1527
+ [[package]]
1528
+ name = "wit-bindgen-core"
1529
+ version = "0.51.0"
1530
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1531
+ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
1532
+ dependencies = [
1533
+ "anyhow",
1534
+ "heck",
1535
+ "wit-parser",
1536
+ ]
1537
+
1538
+ [[package]]
1539
+ name = "wit-bindgen-rust"
1540
+ version = "0.51.0"
1541
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1542
+ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
1543
+ dependencies = [
1544
+ "anyhow",
1545
+ "heck",
1546
+ "indexmap",
1547
+ "prettyplease",
1548
+ "syn",
1549
+ "wasm-metadata",
1550
+ "wit-bindgen-core",
1551
+ "wit-component",
1552
+ ]
1553
+
1554
+ [[package]]
1555
+ name = "wit-bindgen-rust-macro"
1556
+ version = "0.51.0"
1557
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1558
+ checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
1559
+ dependencies = [
1560
+ "anyhow",
1561
+ "prettyplease",
1562
+ "proc-macro2",
1563
+ "quote",
1564
+ "syn",
1565
+ "wit-bindgen-core",
1566
+ "wit-bindgen-rust",
1567
+ ]
1568
+
1569
+ [[package]]
1570
+ name = "wit-component"
1571
+ version = "0.244.0"
1572
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1573
+ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
1574
+ dependencies = [
1575
+ "anyhow",
1576
+ "bitflags",
1577
+ "indexmap",
1578
+ "log",
1579
+ "serde",
1580
+ "serde_derive",
1581
+ "serde_json",
1582
+ "wasm-encoder",
1583
+ "wasm-metadata",
1584
+ "wasmparser",
1585
+ "wit-parser",
1586
+ ]
1587
+
1588
+ [[package]]
1589
+ name = "wit-parser"
1590
+ version = "0.244.0"
1591
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1592
+ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
1593
+ dependencies = [
1594
+ "anyhow",
1595
+ "id-arena",
1596
+ "indexmap",
1597
+ "log",
1598
+ "semver",
1599
+ "serde",
1600
+ "serde_derive",
1601
+ "serde_json",
1602
+ "unicode-xid",
1603
+ "wasmparser",
1604
+ ]
1605
+
1606
+ [[package]]
1607
+ name = "zerocopy"
1608
+ version = "0.8.48"
1609
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1610
+ checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
1611
+ dependencies = [
1612
+ "zerocopy-derive",
1613
+ ]
1614
+
1615
+ [[package]]
1616
+ name = "zerocopy-derive"
1617
+ version = "0.8.48"
1618
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1619
+ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
1620
+ dependencies = [
1621
+ "proc-macro2",
1622
+ "quote",
1623
+ "syn",
1624
+ ]
1625
+
1626
+ [[package]]
1627
+ name = "zmij"
1628
+ version = "1.0.21"
1629
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1630
+ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "solverforge-lessons"
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-lessons"
10
+ path = "src/main.rs"
11
+
12
+ [dependencies]
13
+ solverforge = { version = "0.13.1", features = ["serde", "console", "verbose-logging"] }
14
+ solverforge-ui = { version = "0.6.5" }
15
+ # Web server
16
+ axum = "0.8.9"
17
+ tokio = { version = "1.52.1", features = ["full"] }
18
+ tokio-stream = { version = "0.1.18", features = ["sync"] }
19
+ tower-http = { version = "0.6.8", features = ["fs", "cors"] }
20
+ tower = "0.5.3"
21
+
22
+ # Serialization
23
+ serde = { version = "1.0.228", features = ["derive"] }
24
+ serde_json = "1.0.149"
25
+
26
+ # Utilities
27
+ uuid = { version = "1.23.1", features = ["v4", "serde"] }
28
+ parking_lot = "0.12.5"
29
+ chrono = { version = "0.4.44", features = ["serde"] }
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build for solverforge-lessons.
2
+ FROM rust:1.95-alpine AS builder
3
+
4
+ # Install build dependencies
5
+ RUN apk add --no-cache musl-dev
6
+
7
+ WORKDIR /build
8
+
9
+ COPY Cargo.toml Cargo.lock ./
10
+ COPY src/ ./src/
11
+ COPY static/ ./static/
12
+ COPY solver.toml ./solver.toml
13
+
14
+ # Build release binary with musl target for static linking
15
+ RUN cargo build --release --target x86_64-unknown-linux-musl
16
+
17
+ FROM alpine:latest
18
+
19
+ RUN apk add --no-cache ca-certificates
20
+
21
+ WORKDIR /app
22
+
23
+ # Copy binary from builder (musl static binary)
24
+ COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/solverforge-lessons ./solverforge-lessons
25
+
26
+ # Copy static files
27
+ COPY --from=builder /build/static/ ./static/
28
+
29
+ # Copy solver config
30
+ COPY --from=builder /build/solver.toml ./solver.toml
31
+
32
+ ENV PORT=7860
33
+
34
+ # Expose the same port the container binds to by default.
35
+ EXPOSE 7860
36
+
37
+ # Run the application
38
+ CMD ["./solverforge-lessons"]
Makefile ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolverForge Lessons Makefile
2
+ # Rust + frontend + Space-oriented local build system.
3
+
4
+ SHELL := /bin/sh
5
+ .SHELLFLAGS := -eu -c
6
+ unexport BASH_FUNC_mc%%
7
+
8
+ # ============== Colors & Symbols ==============
9
+ GREEN := \033[92m
10
+ EMERALD := \033[38;2;16;185;129m
11
+ CYAN := \033[96m
12
+ YELLOW := \033[93m
13
+ RED := \033[91m
14
+ GRAY := \033[90m
15
+ BOLD := \033[1m
16
+ RESET := \033[0m
17
+
18
+ CHECK := OK
19
+ CROSS := FAIL
20
+ ARROW := =>
21
+ PROGRESS := ..
22
+
23
+ # ============== Project Metadata ==============
24
+ APP_NAME := solverforge-lessons
25
+ PACKAGE_NAME := solverforge-lessons
26
+ VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
27
+ RELEASE_TAG := $(PACKAGE_NAME)@$(VERSION)
28
+ RUST_VERSION := 1.95+
29
+ PORT ?= 7860
30
+ E2E_PORT ?= 7960
31
+ DOCKER_IMAGE ?= $(PACKAGE_NAME)
32
+ DOCKER_CONTEXT ?= .
33
+ DOCKERFILE_PATH := Dockerfile
34
+
35
+ # ============== Phony Targets ==============
36
+ .PHONY: banner help doctor build build-release run run-release test test-rust \
37
+ test-frontend-syntax test-frontend test-e2e test-slow test-one lint fmt \
38
+ fmt-check clippy check ci-local space-ci space-build space-run docker-build \
39
+ docker-run pre-release release-ci release-info version clean watch require-node require-docker
40
+
41
+ .DEFAULT_GOAL := help
42
+
43
+ # ============== Banner ==============
44
+ banner:
45
+ @printf "$(EMERALD)$(BOLD) ____ _ _____\n"
46
+ @printf " / ___| ___ | |_ _____ _ __| ___|__ _ __ __ _ ___\n"
47
+ @printf " \\___ \\\\ / _ \\\\| \\\\ \\\\ / / _ \\\\ '__| |_ / _ \\\\| '__/ _\` |/ _ \\\\\n"
48
+ @printf " ___) | (_) | |\\\\ V / __/ | | _| (_) | | | (_| | __/\n"
49
+ @printf " |____/ \\\\___/|_| \\_/ \\___|_| |_| \\___/|_| \\__, |\\___|\n"
50
+ @printf " |___/$(RESET)\n"
51
+ @printf " $(GRAY)v$(VERSION)$(RESET) $(EMERALD)Lessons demo build system$(RESET)\n\n"
52
+
53
+ # ============== Environment Checks ==============
54
+ require-node:
55
+ @command -v node >/dev/null 2>&1 || (printf "$(RED)$(CROSS) node is required for frontend validation$(RESET)\n" && exit 1)
56
+
57
+ require-docker:
58
+ @command -v docker >/dev/null 2>&1 || (printf "$(RED)$(CROSS) docker is required for Space/Docker targets$(RESET)\n" && exit 1)
59
+
60
+ doctor: banner
61
+ @printf "$(CYAN)$(BOLD)Environment Check$(RESET)\n\n"
62
+ @missing=0; \
63
+ if command -v cargo >/dev/null 2>&1; then \
64
+ printf "$(GREEN)$(CHECK) cargo: $$(cargo --version)$(RESET)\n"; \
65
+ else \
66
+ printf "$(RED)$(CROSS) cargo not found$(RESET)\n"; missing=1; \
67
+ fi; \
68
+ if command -v rustc >/dev/null 2>&1; then \
69
+ printf "$(GREEN)$(CHECK) rustc: $$(rustc --version)$(RESET)\n"; \
70
+ else \
71
+ printf "$(RED)$(CROSS) rustc not found$(RESET)\n"; missing=1; \
72
+ fi; \
73
+ if command -v node >/dev/null 2>&1; then \
74
+ printf "$(GREEN)$(CHECK) node: $$(node --version)$(RESET)\n"; \
75
+ else \
76
+ printf "$(RED)$(CROSS) node not found$(RESET)\n"; missing=1; \
77
+ fi; \
78
+ if command -v docker >/dev/null 2>&1; then \
79
+ printf "$(GREEN)$(CHECK) docker: $$(docker --version)$(RESET)\n"; \
80
+ else \
81
+ printf "$(YELLOW)! docker not found; Space/Docker targets will be unavailable$(RESET)\n"; \
82
+ fi; \
83
+ printf "$(GRAY)Docker build context: $(DOCKER_CONTEXT)$(RESET)\n"; \
84
+ printf "$(GRAY)Default app port: $(PORT)$(RESET)\n"; \
85
+ printf "$(GRAY)Browser smoke port: $(E2E_PORT)$(RESET)\n"; \
86
+ if [ $$missing -ne 0 ]; then exit 1; fi
87
+ @printf "\n"
88
+
89
+ # ============== Build & Run ==============
90
+ build: banner
91
+ @printf "$(ARROW) $(BOLD)Building $(PACKAGE_NAME)...$(RESET)\n"
92
+ @cargo build --bin $(APP_NAME) && \
93
+ printf "$(GREEN)$(CHECK) Debug build successful$(RESET)\n\n" || \
94
+ (printf "$(RED)$(CROSS) Debug build failed$(RESET)\n\n" && exit 1)
95
+
96
+ build-release: banner
97
+ @printf "$(ARROW) $(BOLD)Building release binary...$(RESET)\n"
98
+ @cargo build --release --bin $(APP_NAME) && \
99
+ printf "$(GREEN)$(CHECK) Release build successful$(RESET)\n\n" || \
100
+ (printf "$(RED)$(CROSS) Release build failed$(RESET)\n\n" && exit 1)
101
+
102
+ run:
103
+ @printf "$(ARROW) Running $(PACKAGE_NAME) on port $(PORT)...\n"
104
+ @PORT=$(PORT) cargo run --bin $(APP_NAME)
105
+
106
+ run-release:
107
+ @printf "$(ARROW) Running release build on port $(PORT)...\n"
108
+ @PORT=$(PORT) cargo run --release --bin $(APP_NAME)
109
+
110
+ # ============== Test Targets ==============
111
+ test: test-rust test-frontend test-e2e
112
+ @printf "\n$(GREEN)$(BOLD)$(CHECK) Standard validation passed$(RESET)\n\n"
113
+
114
+ test-rust: banner
115
+ @printf "$(ARROW) $(BOLD)Running cargo test --quiet...$(RESET)\n"
116
+ @cargo test --quiet && \
117
+ printf "\n$(GREEN)$(CHECK) Rust tests passed$(RESET)\n\n" || \
118
+ (printf "\n$(RED)$(CROSS) Rust tests failed$(RESET)\n\n" && exit 1)
119
+
120
+ test-frontend-syntax: require-node
121
+ @printf "$(PROGRESS) Checking frontend module syntax...\n"
122
+ @find static -name '*.js' -print0 | xargs -0 -n1 node --check && \
123
+ printf "$(GREEN)$(CHECK) Frontend syntax checks passed$(RESET)\n" || \
124
+ (printf "$(RED)$(CROSS) Frontend syntax checks failed$(RESET)\n" && exit 1)
125
+
126
+ test-frontend: test-frontend-syntax
127
+ @printf "$(GREEN)$(CHECK) Frontend validation passed$(RESET)\n"
128
+
129
+ test-e2e: build-release require-node
130
+ @printf "$(PROGRESS) Running browser smoke test on port $(E2E_PORT)...\n"
131
+ @log=$$(mktemp); \
132
+ PORT=$(E2E_PORT) target/release/$(APP_NAME) >"$$log" 2>&1 & \
133
+ pid=$$!; \
134
+ trap 'kill $$pid >/dev/null 2>&1 || true; rm -f "$$log"' EXIT INT TERM; \
135
+ i=0; \
136
+ until curl -fsS "http://127.0.0.1:$(E2E_PORT)/" >/dev/null 2>&1; do \
137
+ i=$$((i + 1)); \
138
+ if [ $$i -ge 60 ]; then \
139
+ printf "$(RED)$(CROSS) app did not become ready$(RESET)\n"; \
140
+ cat "$$log"; \
141
+ exit 1; \
142
+ fi; \
143
+ sleep 0.2; \
144
+ done; \
145
+ node --input-type=module -e 'import { chromium } from "playwright"; const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || "/usr/bin/chromium", headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, colorScheme: "light" }); await page.goto("http://127.0.0.1:$(E2E_PORT)/", { waitUntil: "networkidle", timeout: 30000 }); await page.getByText("Cohort Timetables", { exact: true }).waitFor({ timeout: 30000 }); const body = await page.locator("body").innerText(); if (!body.includes("SolverForge Lessons")) throw new Error("missing product title"); if (!body.includes("Cohort Timetables")) throw new Error("missing cohort timetable view"); if (body.includes("1W") || body.includes("2W") || body.includes("4W")) throw new Error("fixed lesson timetable should not render zoom presets"); if (body.includes("Room null") || body.includes("| null")) throw new Error("rendered null assignment text"); const nullAttrs = await page.locator("[role=\"null\"], [aria-label=\"null\"], [tabindex=\"null\"]").count(); if (nullAttrs !== 0) throw new Error(`found ${nullAttrs} null accessibility attributes`); await browser.close();'; \
146
+ kill $$pid >/dev/null 2>&1 || true; \
147
+ printf "$(GREEN)$(CHECK) Browser smoke test passed$(RESET)\n"
148
+
149
+ test-slow: banner
150
+ @printf "$(ARROW) $(BOLD)Running large demo acceptance solve...$(RESET)\n"
151
+ @cargo test large_demo_solves_to_feasible_progressing_schedule -- --ignored --nocapture && \
152
+ printf "\n$(GREEN)$(CHECK) Slow acceptance solve passed$(RESET)\n\n" || \
153
+ (printf "\n$(RED)$(CROSS) Slow acceptance solve failed$(RESET)\n\n" && exit 1)
154
+
155
+ test-one:
156
+ @test -n "$(TEST)" || (printf "$(RED)$(CROSS) TEST=name is required$(RESET)\n" && exit 1)
157
+ @printf "$(PROGRESS) Running test: $(YELLOW)$(TEST)$(RESET)\n"
158
+ @RUST_LOG=info cargo test "$(TEST)" -- --nocapture
159
+
160
+ # ============== Lint & Format ==============
161
+ fmt:
162
+ @printf "$(PROGRESS) Formatting Rust code...\n"
163
+ @cargo fmt
164
+ @printf "$(GREEN)$(CHECK) Code formatted$(RESET)\n"
165
+
166
+ fmt-check:
167
+ @printf "$(PROGRESS) Checking Rust formatting...\n"
168
+ @cargo fmt --check && \
169
+ printf "$(GREEN)$(CHECK) Formatting valid$(RESET)\n" || \
170
+ (printf "$(RED)$(CROSS) Formatting issues found$(RESET)\n" && exit 1)
171
+
172
+ clippy:
173
+ @printf "$(PROGRESS) Running clippy...\n"
174
+ @cargo clippy --all-targets -- -D warnings && \
175
+ printf "$(GREEN)$(CHECK) Clippy passed$(RESET)\n" || \
176
+ (printf "$(RED)$(CROSS) Clippy warnings found$(RESET)\n" && exit 1)
177
+
178
+ lint: fmt-check clippy test-frontend-syntax
179
+ @printf "\n$(GREEN)$(BOLD)$(CHECK) Lint checks passed$(RESET)\n\n"
180
+
181
+ check: lint test
182
+
183
+ # ============== Space & Docker ==============
184
+ docker-build: require-docker
185
+ @printf "$(PROGRESS) Building Docker image $(DOCKER_IMAGE)...\n"
186
+ @docker build -f "$(DOCKERFILE_PATH)" -t "$(DOCKER_IMAGE)" "$(DOCKER_CONTEXT)" && \
187
+ printf "$(GREEN)$(CHECK) Docker image built$(RESET)\n" || \
188
+ (printf "$(RED)$(CROSS) Docker build failed$(RESET)\n" && exit 1)
189
+
190
+ docker-run: require-docker
191
+ @printf "$(ARROW) Running $(DOCKER_IMAGE) on port $(PORT)...\n"
192
+ @docker run --rm -it -e PORT=$(PORT) -p $(PORT):$(PORT) "$(DOCKER_IMAGE)"
193
+
194
+ space-build: docker-build
195
+
196
+ space-run: space-build
197
+ @printf "$(GREEN)$(CHECK) Starting local container that mirrors the Space image$(RESET)\n"
198
+ @$(MAKE) docker-run --no-print-directory PORT=$(PORT) DOCKER_IMAGE=$(DOCKER_IMAGE)
199
+
200
+ space-ci: ci-local
201
+
202
+ # ============== CI & Release Validation ==============
203
+ ci-local: banner
204
+ @printf "$(CYAN)$(BOLD)Local Space Validation Pipeline$(RESET)\n\n"
205
+ @printf "$(PROGRESS) Step 1/5: Format check...\n"
206
+ @$(MAKE) fmt-check --no-print-directory
207
+ @printf "$(PROGRESS) Step 2/5: Clippy...\n"
208
+ @$(MAKE) clippy --no-print-directory
209
+ @printf "$(PROGRESS) Step 3/5: Release build...\n"
210
+ @$(MAKE) build-release --no-print-directory
211
+ @printf "$(PROGRESS) Step 4/5: Standard test surface...\n"
212
+ @$(MAKE) test --no-print-directory
213
+ @printf "$(PROGRESS) Step 5/5: Docker/Space image build...\n"
214
+ @$(MAKE) space-build --no-print-directory
215
+ @printf "\n$(GREEN)$(BOLD)$(CHECK) LOCAL SPACE VALIDATION PASSED$(RESET)\n\n"
216
+
217
+ pre-release: banner
218
+ @printf "$(CYAN)$(BOLD)Pre-Release Validation v$(VERSION)$(RESET)\n\n"
219
+ @$(MAKE) ci-local --no-print-directory
220
+ @printf "$(PROGRESS) Final step: slow acceptance solve...\n"
221
+ @$(MAKE) test-slow --no-print-directory
222
+ @printf "$(GREEN)$(BOLD)$(CHECK) Ready for publication or Space update$(RESET)\n\n"
223
+
224
+ release-ci: ci-local
225
+ @printf "$(GREEN)$(BOLD)$(CHECK) Release CI passed for $(RELEASE_TAG)$(RESET)\n\n"
226
+
227
+ release-info:
228
+ @printf "$(CYAN)Package:$(RESET) $(YELLOW)$(BOLD)$(PACKAGE_NAME)$(RESET)\n"
229
+ @printf "$(CYAN)Version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n"
230
+ @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n"
231
+
232
+ # ============== Metadata & Cleanup ==============
233
+ version:
234
+ @printf "$(CYAN)Current version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n"
235
+ @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n"
236
+ @printf "$(CYAN)Default port:$(RESET) $(YELLOW)$(BOLD)$(PORT)$(RESET)\n"
237
+ @printf "$(CYAN)Browser smoke port:$(RESET) $(YELLOW)$(BOLD)$(E2E_PORT)$(RESET)\n"
238
+
239
+ clean:
240
+ @printf "$(ARROW) Cleaning build artifacts...\n"
241
+ @cargo clean
242
+ @printf "$(GREEN)$(CHECK) Clean complete$(RESET)\n"
243
+
244
+ watch:
245
+ @printf "$(ARROW) Watching and rerunning the app on port $(PORT)...\n"
246
+ @cargo watch --version >/dev/null 2>&1 || \
247
+ (printf "$(RED)$(CROSS) cargo-watch is required for make watch$(RESET)\n" && exit 1)
248
+ @PORT=$(PORT) cargo watch -x "run --bin $(APP_NAME)"
249
+
250
+ # ============== Help ==============
251
+ help: banner
252
+ @printf "$(CYAN)$(BOLD)Environment:$(RESET)\n"
253
+ @printf " $(GREEN)make doctor$(RESET) - Check local cargo/rustc/node readiness\n"
254
+ @printf "\n$(CYAN)$(BOLD)Build & Run:$(RESET)\n"
255
+ @printf " $(GREEN)make build$(RESET) - Build the app in debug mode\n"
256
+ @printf " $(GREEN)make build-release$(RESET) - Build the app in release mode\n"
257
+ @printf " $(GREEN)make run$(RESET) - Run locally on port $(PORT)\n"
258
+ @printf " $(GREEN)make run-release$(RESET) - Run the release build on port $(PORT)\n"
259
+ @printf "\n$(CYAN)$(BOLD)Tests & Validation:$(RESET)\n"
260
+ @printf " $(GREEN)make test$(RESET) - Run Rust, frontend syntax, and browser smoke checks\n"
261
+ @printf " $(GREEN)make test-rust$(RESET) - Run Rust tests only\n"
262
+ @printf " $(GREEN)make test-frontend$(RESET) - Run frontend syntax checks\n"
263
+ @printf " $(GREEN)make test-e2e$(RESET) - Run a Playwright browser smoke check\n"
264
+ @printf " $(GREEN)make test-slow$(RESET) - Run the ignored large-demo acceptance solve\n"
265
+ @printf " $(GREEN)make test-one TEST=name$(RESET) - Run a specific Rust test with output\n"
266
+ @printf " $(GREEN)make lint$(RESET) - Run fmt-check, clippy, and frontend syntax checks\n"
267
+ @printf " $(GREEN)make check$(RESET) - Run lint plus standard tests\n"
268
+ @printf " $(GREEN)make ci-local$(RESET) - Run local Space validation pipeline\n"
269
+ @printf " $(GREEN)make release-ci$(RESET) - Run the tag-publish CI gate for this app\n"
270
+ @printf " $(GREEN)make pre-release$(RESET) - Run ci-local plus slow acceptance solve\n"
271
+ @printf "\n$(CYAN)$(BOLD)Space & Docker:$(RESET)\n"
272
+ @printf " $(GREEN)make space-build$(RESET) - Build the Docker image used for Space deployment\n"
273
+ @printf " $(GREEN)make space-run$(RESET) - Build and run that image locally on port $(PORT)\n"
274
+ @printf " $(GREEN)make docker-build$(RESET) - Build the Docker image directly\n"
275
+ @printf " $(GREEN)make docker-run$(RESET) - Run the Docker image directly\n"
276
+ @printf "\n$(CYAN)$(BOLD)Other:$(RESET)\n"
277
+ @printf " $(GREEN)make fmt$(RESET) - Format Rust code\n"
278
+ @printf " $(GREEN)make release-info$(RESET) - Show package version and app-scoped release tag\n"
279
+ @printf " $(GREEN)make version$(RESET) - Show version and default ports\n"
280
+ @printf " $(GREEN)make clean$(RESET) - Clean build artifacts\n"
281
+ @printf " $(GREEN)make watch$(RESET) - Watch source files and rerun the app\n"
282
+ @printf " $(GREEN)make help$(RESET) - Show this help message\n"
283
+ @printf "\n$(GRAY)Rust version required: $(RUST_VERSION)$(RESET)\n"
284
+ @printf "$(GRAY)Current version: v$(VERSION)$(RESET)\n"
285
+ @printf "$(GRAY)Release tag: $(RELEASE_TAG)$(RESET)\n"
README.md ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: SolverForge Lessons
3
+ emoji: 📚
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: apache-2.0
10
+ short_description: SolverForge lesson scheduling example
11
+ ---
12
+
13
+ # SolverForge Lessons
14
+
15
+ ![SolverForge Lessons screenshot](docs/screenshot.png)
16
+
17
+ `solverforge-lessons` is a SolverForge lesson-timetabling app with retained
18
+ jobs, cohort timetable views, and a browser schedule workspace.
19
+
20
+ It answers one concrete question:
21
+
22
+ "Given lessons, teachers, student groups, rooms, and weekly timeslots, which
23
+ timeslot and room should each lesson receive?"
24
+
25
+ ## Acknowledgement
26
+
27
+ Thanks to `@benabel` (Prof. Benjamin Abel), whose referral is also credited in
28
+ the core SolverForge README.
29
+
30
+ ## Quick Start
31
+
32
+ ```sh
33
+ make run-release
34
+ ```
35
+
36
+ Then open `http://localhost:7860`.
37
+
38
+ To inspect the supported command surface:
39
+
40
+ ```sh
41
+ make help
42
+ ```
43
+
44
+ ## Documentation Map
45
+
46
+ - `README.md`
47
+ Quick start, model concepts, validation, REST API, and solver policy.
48
+ - `WIREFRAME.md`
49
+ As-built architecture and runtime/data flow across backend, runtime, and UI.
50
+ - `AGENTS.md`
51
+ Codex-facing maintenance, validation, and documentation rules.
52
+ - `Makefile`
53
+ Supported local commands for development, validation, Docker, and Space work.
54
+ - `Dockerfile`
55
+ Docker Space image build using Rust 1.95 and the declared crates.io line.
56
+
57
+ ## Current Dependency Shape
58
+
59
+ - Package: `solverforge-lessons`; version is declared in `Cargo.toml`
60
+ - Release binary: `solverforge-lessons`
61
+ - Rust: `1.95`
62
+ - SolverForge runtime: `solverforge` `0.13.1`
63
+ - Browser UI assets: `solverforge-ui` `0.6.5`
64
+ - Scaffold metadata: `solverforge-cli` `2.0.4` in `solverforge.app.toml`
65
+
66
+ The app serves registry-backed Rust dependencies, local static browser modules,
67
+ and Axum API routes from one process.
68
+
69
+ ## Model Concepts
70
+
71
+ - `Timeslot`, `Teacher`, `Group`, and `Room` are problem facts: input data the
72
+ solver reads but does not move.
73
+ - `Lesson` is the planning entity: each subject meeting needs a timeslot and a
74
+ room.
75
+ - `Lesson.timeslot_idx` is a scalar planning variable: the timeslot index
76
+ SolverForge changes.
77
+ - `Lesson.room_idx` is a scalar planning variable: the room index SolverForge
78
+ changes.
79
+ - `Plan` is the planning solution with the current `HardMediumSoftScore`.
80
+
81
+ The app ships one deterministic `LARGE` dataset with 300 lessons, 12 student
82
+ groups, 40 weekly timeslots, and 10 typed rooms. It starts with every lesson
83
+ unassigned, so the initial score is `0hard/-600medium/0soft`.
84
+
85
+ ## Constraints
86
+
87
+ Hard constraints:
88
+
89
+ - Teachers can teach only in available timeslots.
90
+ - Student groups can attend only in available timeslots.
91
+ - A room must be large enough for the assigned group.
92
+ - A teacher cannot teach overlapping lessons.
93
+ - A group cannot attend overlapping lessons.
94
+ - A room cannot host overlapping lessons.
95
+
96
+ Medium constraints:
97
+
98
+ - Every lesson should receive a timeslot.
99
+ - Every lesson should receive a room.
100
+
101
+ Soft constraints:
102
+
103
+ - Room kind should match the subject.
104
+ - Lessons should avoid late-day slots when possible.
105
+ - The same subject should not repeat twice in one day for a cohort.
106
+
107
+ ## REST API
108
+
109
+ - `GET /health`
110
+ - `GET /info`
111
+ - `GET /demo-data`
112
+ - `GET /demo-data/{id}`
113
+ - `POST /jobs`
114
+ - `GET /jobs/{id}`
115
+ - `DELETE /jobs/{id}`
116
+ - `GET /jobs/{id}/status`
117
+ - `GET /jobs/{id}/snapshot`
118
+ - `GET /jobs/{id}/analysis`
119
+ - `POST /jobs/{id}/pause`
120
+ - `POST /jobs/{id}/resume`
121
+ - `POST /jobs/{id}/cancel`
122
+ - `GET /jobs/{id}/events`
123
+
124
+ `snapshot_revision={n}` is optional for snapshots and analysis. SSE clients
125
+ receive a bootstrap event and then live retained-job events.
126
+
127
+ ## Solver Policy
128
+
129
+ `solver.toml` is embedded by `Plan` and is the runtime source of truth.
130
+
131
+ - `cheapest_insertion` assigns timeslots and rooms during construction.
132
+ - `construction_obligation = "assign_when_candidate_exists"` makes
133
+ construction fill variables when a legal candidate exists.
134
+ - `value_candidate_limit = 40` bounds construction candidate scanning.
135
+ - Local search uses `late_acceptance` with an accepted-count forager.
136
+ - Solving stops after 30 seconds.
137
+
138
+ The slow acceptance test expects solving to reach hard and medium feasibility
139
+ from the fully unassigned public instance while soft penalties continue to
140
+ represent timetable quality.
141
+
142
+ ## Validation
143
+
144
+ Standard validation:
145
+
146
+ ```sh
147
+ make test
148
+ ```
149
+
150
+ Full local validation:
151
+
152
+ ```sh
153
+ make ci-local
154
+ ```
155
+
156
+ Slow acceptance solve:
157
+
158
+ ```sh
159
+ make test-slow
160
+ ```
161
+
162
+ `make test` runs Rust tests, frontend syntax checks, and a Playwright browser
163
+ smoke. `make ci-local` adds formatting, clippy, release build, and Docker image
164
+ build. `make pre-release` runs `ci-local` plus the slow acceptance solve.
165
+
166
+ ## Hugging Face Space Deployment
167
+
168
+ This repo is Docker-Space ready. The Space reads the README front matter,
169
+ builds `Dockerfile`, and expects the app to bind `PORT=7860`.
170
+
171
+ Local Space-equivalent commands:
172
+
173
+ ```sh
174
+ make space-build
175
+ make space-run
176
+ ```
177
+
178
+ ## Read The Code In This Order
179
+
180
+ 1. `src/domain/mod.rs`
181
+ The `planning_model!` manifest and public domain exports.
182
+ 2. `src/domain/plan.rs`
183
+ The `Plan` solution, fact collections, lesson entities, and normalized
184
+ index fields.
185
+ 3. `src/domain/lesson.rs`
186
+ The planning entity and the two scalar planning variables.
187
+ 4. `src/domain/{timeslot,teacher,group,room}.rs`
188
+ The problem facts the constraints join against.
189
+ 5. `src/constraints/mod.rs` and `src/constraints/*.rs`
190
+ The score model, one timetable rule per file.
191
+ 6. `src/data/data_seed/entrypoints.rs`
192
+ Public demo-data IDs and generator dispatch.
193
+ 7. `src/data/data_seed/large.rs`
194
+ The published instance builder.
195
+ 8. `src/solver/service.rs`
196
+ Retained-job orchestration over `SolverManager<Plan>`.
197
+ 9. `src/api/routes.rs`, `src/api/dto.rs`, and `src/api/sse.rs`
198
+ HTTP routes, transport DTOs, and live-event streaming.
199
+ 10. `static/app.js` and `static/views.js`
200
+ Browser boot sequence, solver controls, and timetable rendering.
201
+
202
+ ## Project Shape
203
+
204
+ - `src/domain/`
205
+ Planning model, domain types, planning variables, and derived indexes.
206
+ - `src/constraints/`
207
+ Incremental SolverForge scoring rules.
208
+ - `src/data/`
209
+ Deterministic lesson timetable demo-data generator.
210
+ - `src/solver/`
211
+ Retained-job facade and runtime event payload formatting.
212
+ - `src/api/`
213
+ Axum routes, DTOs, and SSE endpoint.
214
+ - `static/`
215
+ Browser workspace built on stock `solverforge-ui` assets.
216
+ - `tests/e2e/`
217
+ Playwright browser smoke for the served app.
WIREFRAME.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SolverForge Lessons Wireframe
2
+
3
+ `README.md` explains how to run and use the app. This file records the current
4
+ runtime shape so maintainers can keep backend, UI, and publication metadata in
5
+ sync.
6
+
7
+ ## Runtime Flow
8
+
9
+ ```text
10
+ Browser
11
+ |
12
+ | GET /
13
+ v
14
+ Axum server in src/main.rs
15
+ |
16
+ | serves /sf/* from solverforge-ui
17
+ | serves static/* from this app
18
+ | exposes /api/* and /jobs/*
19
+ v
20
+ Retained solver service in src/solver/
21
+ |
22
+ | builds demo data from src/data/
23
+ | solves Plan from src/domain/
24
+ | scores constraints from src/constraints/
25
+ v
26
+ SSE and JSON DTOs in src/api/
27
+ ```
28
+
29
+ ## Browser Surface
30
+
31
+ The first viewport shows the standard SolverForge UI shell with tabs for group,
32
+ room, teacher, data, and REST API views. The Solve button starts the retained
33
+ job and the status strip reports the current score, lifecycle state, and
34
+ constraint count.
35
+
36
+ ## Model
37
+
38
+ Lessons are planning entities. Teachers, student groups, rooms, and timeslots
39
+ are problem facts. The solver assigns each lesson to a timeslot and room while
40
+ avoiding teacher, group, and room conflicts and preferring better timetable
41
+ quality.
42
+
43
+ ## Key Files
44
+
45
+ - `Cargo.toml`: crate and binary identity for `solverforge-lessons`.
46
+ - `solverforge.app.toml`: app metadata, entities, facts, variables, and
47
+ constraints.
48
+ - `solver.toml`: solver termination and phase policy.
49
+ - `src/main.rs`: single-process Axum server used locally and in the Space.
50
+ - `static/sf-config.json`: visible title and view metadata.
51
+ - `docs/screenshot.png`: current browser screenshot.
docs/screenshot.png ADDED

Git LFS Details

  • SHA256: 95bbbdb169fb6277b1be7ae04fe709ee6f4b0618675919f13453876a9b20a87f
  • Pointer size: 131 Bytes
  • Size of remote file: 281 kB
solver.toml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [[phases]]
2
+ type = "construction_heuristic"
3
+ construction_heuristic_type = "cheapest_insertion"
4
+ construction_obligation = "assign_when_candidate_exists"
5
+ value_candidate_limit = 40
6
+
7
+ [[phases]]
8
+ type = "local_search"
9
+ [phases.acceptor]
10
+ type = "late_acceptance"
11
+ late_acceptance_size = 400
12
+ [phases.forager]
13
+ type = "accepted_count"
14
+ limit = 4
15
+
16
+ [termination]
17
+ seconds_spent_limit = 30
solverforge.app.toml ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [app]
2
+ name = "solverforge-lessons"
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
+
11
+ [demo]
12
+ default_size = "LARGE"
13
+ available_sizes = [
14
+ "LARGE",
15
+ ]
16
+
17
+ [solution]
18
+ name = "Plan"
19
+ score = "HardMediumSoftScore"
20
+
21
+ [[facts]]
22
+ name = "timeslot"
23
+ plural = "timeslots"
24
+ kind = "problem_fact"
25
+
26
+ [[facts]]
27
+ name = "teacher"
28
+ plural = "teachers"
29
+ kind = "problem_fact"
30
+
31
+ [[facts]]
32
+ name = "group"
33
+ plural = "groups"
34
+ kind = "problem_fact"
35
+
36
+ [[facts]]
37
+ name = "room"
38
+ plural = "rooms"
39
+ kind = "problem_fact"
40
+
41
+ [[entities]]
42
+ name = "lesson"
43
+ plural = "lessons"
44
+ kind = "planning_entity"
45
+
46
+ [[variables]]
47
+ entity = "lesson"
48
+ entity_plural = "lessons"
49
+ field = "timeslot_idx"
50
+ kind = "scalar"
51
+ range = "timeslots"
52
+ elements = ""
53
+ allows_unassigned = false
54
+ enabled = true
55
+
56
+ [[variables]]
57
+ entity = "lesson"
58
+ entity_plural = "lessons"
59
+ field = "room_idx"
60
+ kind = "scalar"
61
+ range = "rooms"
62
+ elements = ""
63
+ allows_unassigned = false
64
+ enabled = true
65
+
66
+ [[constraints]]
67
+ name = "assign_timeslot"
68
+ module = "assign_timeslot"
69
+ enabled = true
70
+
71
+ [[constraints]]
72
+ name = "assign_room"
73
+ module = "assign_room"
74
+ enabled = true
75
+
76
+ [[constraints]]
77
+ name = "teacher_availability"
78
+ module = "teacher_availability"
79
+ enabled = true
80
+
81
+ [[constraints]]
82
+ name = "group_availability"
83
+ module = "group_availability"
84
+ enabled = true
85
+
86
+ [[constraints]]
87
+ name = "room_kind"
88
+ module = "room_kind"
89
+ enabled = true
90
+
91
+ [[constraints]]
92
+ name = "room_capacity"
93
+ module = "room_capacity"
94
+ enabled = true
95
+
96
+ [[constraints]]
97
+ name = "no_group_conflict"
98
+ module = "no_group_conflict"
99
+ enabled = true
100
+
101
+ [[constraints]]
102
+ name = "no_room_conflict"
103
+ module = "no_room_conflict"
104
+ enabled = true
105
+
106
+ [[constraints]]
107
+ name = "no_teacher_conflict"
108
+ module = "no_teacher_conflict"
109
+ enabled = true
110
+
111
+ [[constraints]]
112
+ name = "late_lesson"
113
+ module = "late_lesson"
114
+ enabled = true
115
+
116
+ [[constraints]]
117
+ name = "repeated_subject_day"
118
+ module = "repeated_subject_day"
119
+ enabled = true
src/api/dto.rs ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Browser-facing JSON types for the lessons API.
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
+ HardMediumSoftScore, SolverLifecycleState, SolverSnapshot, SolverSnapshotAnalysis,
11
+ SolverStatus, SolverTelemetry, SolverTerminalReason,
12
+ };
13
+ use std::time::Duration;
14
+
15
+ use crate::domain::Plan;
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
+ /// One row in the browser's score-analysis panel.
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: &Plan) -> 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<Plan, serde_json::Error> {
120
+ let mut fields = self.fields.clone();
121
+ let _ = &self.score;
122
+ fields.insert("score".to_string(), Value::Null);
123
+ let mut plan: Plan = serde_json::from_value(Value::Object(fields))?;
124
+ plan.rebuild_derived_fields();
125
+ Ok(plan)
126
+ }
127
+ }
128
+
129
+ impl TelemetryDto {
130
+ pub fn from_runtime(telemetry: SolverTelemetry) -> Self {
131
+ Self {
132
+ elapsed_ms: duration_to_millis(telemetry.elapsed),
133
+ step_count: telemetry.step_count,
134
+ moves_generated: telemetry.moves_generated,
135
+ moves_evaluated: telemetry.moves_evaluated,
136
+ moves_accepted: telemetry.moves_accepted,
137
+ score_calculations: telemetry.score_calculations,
138
+ generation_ms: duration_to_millis(telemetry.generation_time),
139
+ evaluation_ms: duration_to_millis(telemetry.evaluation_time),
140
+ moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed),
141
+ acceptance_rate: derive_acceptance_rate(
142
+ telemetry.moves_accepted,
143
+ telemetry.moves_evaluated,
144
+ ),
145
+ }
146
+ }
147
+ }
148
+
149
+ impl JobSummaryDto {
150
+ pub fn from_status(job_id: usize, status: &SolverStatus<HardMediumSoftScore>) -> Self {
151
+ Self {
152
+ id: job_id.to_string(),
153
+ job_id: job_id.to_string(),
154
+ lifecycle_state: lifecycle_state_label(status.lifecycle_state),
155
+ terminal_reason: status.terminal_reason.map(terminal_reason_label),
156
+ checkpoint_available: status.checkpoint_available,
157
+ event_sequence: status.event_sequence,
158
+ snapshot_revision: status.latest_snapshot_revision,
159
+ current_score: status.current_score.map(|score| score.to_string()),
160
+ best_score: status.best_score.map(|score| score.to_string()),
161
+ telemetry: TelemetryDto::from_runtime(status.telemetry.clone()),
162
+ }
163
+ }
164
+ }
165
+
166
+ impl JobSnapshotDto {
167
+ pub fn from_snapshot(snapshot: &SolverSnapshot<Plan>) -> Self {
168
+ Self {
169
+ id: snapshot.job_id.to_string(),
170
+ job_id: snapshot.job_id.to_string(),
171
+ snapshot_revision: snapshot.snapshot_revision,
172
+ lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
173
+ terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
174
+ current_score: snapshot.current_score.map(|score| score.to_string()),
175
+ best_score: snapshot.best_score.map(|score| score.to_string()),
176
+ telemetry: TelemetryDto::from_runtime(snapshot.telemetry.clone()),
177
+ solution: PlanDto::from_plan(&snapshot.solution),
178
+ }
179
+ }
180
+ }
181
+
182
+ impl JobAnalysisDto {
183
+ pub fn from_snapshot_analysis(
184
+ snapshot: &SolverSnapshotAnalysis<HardMediumSoftScore>,
185
+ analysis: AnalyzeResponse,
186
+ ) -> Self {
187
+ Self {
188
+ id: snapshot.job_id.to_string(),
189
+ job_id: snapshot.job_id.to_string(),
190
+ snapshot_revision: snapshot.snapshot_revision,
191
+ lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
192
+ terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
193
+ analysis,
194
+ }
195
+ }
196
+ }
197
+
198
+ pub fn analysis_response(
199
+ analysis: &solverforge::ScoreAnalysis<HardMediumSoftScore>,
200
+ ) -> AnalyzeResponse {
201
+ AnalyzeResponse {
202
+ score: analysis.score.to_string(),
203
+ constraints: analysis
204
+ .constraints
205
+ .iter()
206
+ .map(|constraint| ConstraintAnalysisDto {
207
+ name: constraint.name.clone(),
208
+ weight: constraint.weight.to_string(),
209
+ score: constraint.score.to_string(),
210
+ match_count: constraint.match_count,
211
+ })
212
+ .collect(),
213
+ }
214
+ }
215
+
216
+ pub fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str {
217
+ match state {
218
+ SolverLifecycleState::Solving => "SOLVING",
219
+ SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED",
220
+ SolverLifecycleState::Paused => "PAUSED",
221
+ SolverLifecycleState::Completed => "COMPLETED",
222
+ SolverLifecycleState::Cancelled => "CANCELLED",
223
+ SolverLifecycleState::Failed => "FAILED",
224
+ }
225
+ }
226
+
227
+ pub fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str {
228
+ match reason {
229
+ SolverTerminalReason::Completed => "completed",
230
+ SolverTerminalReason::TerminatedByConfig => "terminated_by_config",
231
+ SolverTerminalReason::Cancelled => "cancelled",
232
+ SolverTerminalReason::Failed => "failed",
233
+ }
234
+ }
235
+
236
+ fn duration_to_millis(duration: Duration) -> u64 {
237
+ duration.as_millis().min(u128::from(u64::MAX)) as u64
238
+ }
239
+
240
+ fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 {
241
+ let nanos = elapsed.as_nanos();
242
+ if nanos == 0 {
243
+ 0
244
+ } else {
245
+ let per_second = u128::from(count)
246
+ .saturating_mul(1_000_000_000)
247
+ .checked_div(nanos)
248
+ .unwrap_or(0);
249
+ per_second.min(u128::from(u64::MAX)) as u64
250
+ }
251
+ }
252
+
253
+ fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 {
254
+ if moves_evaluated == 0 {
255
+ 0.0
256
+ } else {
257
+ moves_accepted as f64 / moves_evaluated as f64
258
+ }
259
+ }
260
+
261
+ #[cfg(test)]
262
+ mod tests {
263
+ use super::*;
264
+ use crate::constraints::create_constraints;
265
+ use crate::data::{generate, DemoData};
266
+ use solverforge::ConstraintSet;
267
+
268
+ #[test]
269
+ fn plan_dto_round_trip_restores_lesson_indexes() {
270
+ let dto = PlanDto::from_plan(&generate(DemoData::Large));
271
+ let plan = dto.to_domain().expect("DTO should decode into a plan");
272
+
273
+ for (index, lesson) in plan.lessons.iter().enumerate() {
274
+ assert_eq!(lesson.index, index);
275
+ }
276
+ }
277
+
278
+ #[test]
279
+ fn plan_dto_round_trip_preserves_hard_conflict_detection() {
280
+ let mut plan = generate(DemoData::Large);
281
+ plan.lessons[0].timeslot_idx = Some(0);
282
+ plan.lessons[0].room_idx = Some(0);
283
+ plan.lessons[1].timeslot_idx = Some(0);
284
+ plan.lessons[1].room_idx = Some(0);
285
+
286
+ let dto = PlanDto::from_plan(&plan);
287
+ let round_tripped = dto.to_domain().expect("DTO should decode into a plan");
288
+ let score = create_constraints().evaluate_all(&round_tripped);
289
+
290
+ assert!(
291
+ score.hard() < 0,
292
+ "round-tripped plan must still detect hard conflicts, got {score}"
293
+ );
294
+ }
295
+ }
src/api/mod.rs ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! HTTP transport surface for the lesson-timetabling app.
2
+ //!
3
+ //! Routes decode browser requests, DTOs define the JSON contract, and
4
+ //! `SolverService` owns retained jobs.
5
+
6
+ mod dto;
7
+ mod routes;
8
+ mod sse;
9
+
10
+ pub use dto::PlanDto;
11
+ pub use routes::{router, AppState};
src/api/routes.rs ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! HTTP routes for the lesson-timetabling 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::sse;
17
+ use crate::data::{generate, DemoData};
18
+ use crate::solver::SolverService;
19
+
20
+ /// Shared application state stored once inside Axum.
21
+ pub struct AppState {
22
+ pub solver: SolverService,
23
+ }
24
+
25
+ impl AppState {
26
+ pub fn new() -> Self {
27
+ Self {
28
+ solver: SolverService::new(),
29
+ }
30
+ }
31
+ }
32
+
33
+ impl Default for AppState {
34
+ fn default() -> Self {
35
+ Self::new()
36
+ }
37
+ }
38
+
39
+ /// Registers the public HTTP surface used by the browser and tests.
40
+ pub fn router(state: Arc<AppState>) -> Router {
41
+ Router::new()
42
+ .route("/health", get(health))
43
+ .route("/info", get(info))
44
+ .route("/demo-data", get(list_demo_data))
45
+ .route("/demo-data/{id}", get(get_demo_data))
46
+ .route("/jobs", post(create_job))
47
+ .route("/jobs/{id}", get(get_job).delete(delete_job))
48
+ .route("/jobs/{id}/status", get(get_job_status))
49
+ .route("/jobs/{id}/snapshot", get(get_snapshot))
50
+ .route("/jobs/{id}/analysis", get(analyze_by_id))
51
+ .route("/jobs/{id}/pause", post(pause_job))
52
+ .route("/jobs/{id}/resume", post(resume_job))
53
+ .route("/jobs/{id}/cancel", post(cancel_job))
54
+ .route("/jobs/{id}/events", get(sse::events))
55
+ .with_state(state)
56
+ }
57
+
58
+ #[derive(Serialize)]
59
+ struct HealthResponse {
60
+ status: &'static str,
61
+ }
62
+
63
+ async fn health() -> Json<HealthResponse> {
64
+ Json(HealthResponse { status: "UP" })
65
+ }
66
+
67
+ #[derive(Serialize)]
68
+ #[serde(rename_all = "camelCase")]
69
+ struct InfoResponse {
70
+ name: &'static str,
71
+ version: &'static str,
72
+ solver_engine: &'static str,
73
+ }
74
+
75
+ async fn info() -> Json<InfoResponse> {
76
+ Json(InfoResponse {
77
+ name: env!("CARGO_PKG_NAME"),
78
+ version: env!("CARGO_PKG_VERSION"),
79
+ solver_engine: "SolverForge",
80
+ })
81
+ }
82
+
83
+ #[derive(Serialize)]
84
+ #[serde(rename_all = "camelCase")]
85
+ struct DemoDataCatalogResponse {
86
+ default_id: &'static str,
87
+ available_ids: Vec<&'static str>,
88
+ }
89
+
90
+ async fn list_demo_data() -> Json<DemoDataCatalogResponse> {
91
+ Json(DemoDataCatalogResponse {
92
+ default_id: DemoData::default_demo_data().id(),
93
+ available_ids: DemoData::available_demo_data()
94
+ .iter()
95
+ .map(|demo| demo.id())
96
+ .collect(),
97
+ })
98
+ }
99
+
100
+ async fn get_demo_data(Path(id): Path<String>) -> Result<Json<PlanDto>, StatusCode> {
101
+ let demo = id.parse::<DemoData>().map_err(|_| StatusCode::NOT_FOUND)?;
102
+ let plan = generate(demo);
103
+ Ok(Json(PlanDto::from_plan(&plan)))
104
+ }
105
+
106
+ #[derive(Serialize)]
107
+ #[serde(rename_all = "camelCase")]
108
+ struct CreateJobResponse {
109
+ id: String,
110
+ }
111
+
112
+ async fn create_job(
113
+ State(state): State<Arc<AppState>>,
114
+ Json(dto): Json<PlanDto>,
115
+ ) -> Result<Json<CreateJobResponse>, StatusCode> {
116
+ let plan = dto.to_domain().map_err(|_| StatusCode::BAD_REQUEST)?;
117
+ let id = state
118
+ .solver
119
+ .start_job(plan)
120
+ .map_err(status_from_solver_error)?;
121
+ Ok(Json(CreateJobResponse { id }))
122
+ }
123
+
124
+ async fn get_job(
125
+ State(state): State<Arc<AppState>>,
126
+ Path(id): Path<String>,
127
+ ) -> Result<Json<JobSummaryDto>, StatusCode> {
128
+ let job_id = parse_job_id(&id)?;
129
+ let status = state
130
+ .solver
131
+ .get_status(&id)
132
+ .map_err(status_from_solver_error)?;
133
+ Ok(Json(JobSummaryDto::from_status(job_id, &status)))
134
+ }
135
+
136
+ async fn get_job_status(
137
+ State(state): State<Arc<AppState>>,
138
+ Path(id): Path<String>,
139
+ ) -> Result<Json<JobSummaryDto>, StatusCode> {
140
+ get_job(State(state), Path(id)).await
141
+ }
142
+
143
+ #[derive(Debug, Default, Deserialize)]
144
+ struct SnapshotQuery {
145
+ snapshot_revision: Option<u64>,
146
+ }
147
+
148
+ async fn get_snapshot(
149
+ State(state): State<Arc<AppState>>,
150
+ Path(id): Path<String>,
151
+ Query(query): Query<SnapshotQuery>,
152
+ ) -> Result<Json<JobSnapshotDto>, StatusCode> {
153
+ let snapshot = state
154
+ .solver
155
+ .get_snapshot(&id, query.snapshot_revision)
156
+ .map_err(status_from_solver_error)?;
157
+ Ok(Json(JobSnapshotDto::from_snapshot(&snapshot)))
158
+ }
159
+
160
+ async fn analyze_by_id(
161
+ State(state): State<Arc<AppState>>,
162
+ Path(id): Path<String>,
163
+ Query(query): Query<SnapshotQuery>,
164
+ ) -> Result<Json<JobAnalysisDto>, StatusCode> {
165
+ let snapshot_analysis = state
166
+ .solver
167
+ .analyze_snapshot(&id, query.snapshot_revision)
168
+ .map_err(status_from_solver_error)?;
169
+ let analysis = analysis_response(&snapshot_analysis.analysis);
170
+ Ok(Json(JobAnalysisDto::from_snapshot_analysis(
171
+ &snapshot_analysis,
172
+ analysis,
173
+ )))
174
+ }
175
+
176
+ async fn pause_job(
177
+ State(state): State<Arc<AppState>>,
178
+ Path(id): Path<String>,
179
+ ) -> Result<StatusCode, StatusCode> {
180
+ state.solver.pause(&id).map_err(status_from_solver_error)?;
181
+ Ok(StatusCode::ACCEPTED)
182
+ }
183
+
184
+ async fn resume_job(
185
+ State(state): State<Arc<AppState>>,
186
+ Path(id): Path<String>,
187
+ ) -> Result<StatusCode, StatusCode> {
188
+ state.solver.resume(&id).map_err(status_from_solver_error)?;
189
+ Ok(StatusCode::ACCEPTED)
190
+ }
191
+
192
+ async fn cancel_job(
193
+ State(state): State<Arc<AppState>>,
194
+ Path(id): Path<String>,
195
+ ) -> Result<StatusCode, StatusCode> {
196
+ state.solver.cancel(&id).map_err(status_from_solver_error)?;
197
+ Ok(StatusCode::ACCEPTED)
198
+ }
199
+
200
+ async fn delete_job(
201
+ State(state): State<Arc<AppState>>,
202
+ Path(id): Path<String>,
203
+ ) -> Result<StatusCode, StatusCode> {
204
+ state.solver.delete(&id).map_err(status_from_solver_error)?;
205
+ Ok(StatusCode::NO_CONTENT)
206
+ }
207
+
208
+ fn parse_job_id(id: &str) -> Result<usize, StatusCode> {
209
+ id.parse::<usize>().map_err(|_| StatusCode::NOT_FOUND)
210
+ }
211
+
212
+ fn status_from_solver_error(error: solverforge::SolverManagerError) -> StatusCode {
213
+ match error {
214
+ solverforge::SolverManagerError::NoFreeJobSlots => StatusCode::SERVICE_UNAVAILABLE,
215
+ solverforge::SolverManagerError::JobNotFound { .. } => StatusCode::NOT_FOUND,
216
+ solverforge::SolverManagerError::InvalidStateTransition { .. } => StatusCode::CONFLICT,
217
+ solverforge::SolverManagerError::NoSnapshotAvailable { .. } => StatusCode::CONFLICT,
218
+ solverforge::SolverManagerError::SnapshotNotFound { .. } => StatusCode::NOT_FOUND,
219
+ }
220
+ }
src/api/sse.rs ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Server-sent events for retained lesson 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
+ // Broadcast channels can report that a slow browser missed events. The
42
+ // next retained snapshot/status request is still authoritative, so the
43
+ // stream drops that gap instead of failing the connection.
44
+ Err(_) => None,
45
+ });
46
+
47
+ let stream = bootstrap.chain(live);
48
+
49
+ Ok(Response::builder()
50
+ .header(header::CONTENT_TYPE, "text/event-stream")
51
+ .header(header::CACHE_CONTROL, "no-cache")
52
+ .header("X-Accel-Buffering", "no")
53
+ .body(Body::from_stream(stream))
54
+ .unwrap())
55
+ }
56
+
57
+ fn event_sequence_from_json(json: &str) -> Option<u64> {
58
+ serde_json::from_str::<serde_json::Value>(json)
59
+ .ok()
60
+ .and_then(|value| {
61
+ value
62
+ .get("eventSequence")
63
+ .and_then(serde_json::Value::as_u64)
64
+ })
65
+ }
66
+
67
+ /// Returns true when a live event is already covered by the bootstrap frame.
68
+ fn event_is_not_newer(json: &str, bootstrap_event_sequence: Option<u64>) -> bool {
69
+ let Some(bootstrap_event_sequence) = bootstrap_event_sequence else {
70
+ return false;
71
+ };
72
+ event_sequence_from_json(json)
73
+ .is_some_and(|event_sequence| event_sequence <= bootstrap_event_sequence)
74
+ }
src/constraints/assign_room.rs ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// MEDIUM: Every lesson should receive a room.
6
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
7
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
8
+ .for_each(Plan::lessons())
9
+ .filter(|lesson: &Lesson| lesson.room_idx.is_none())
10
+ .penalize(|_: &Lesson| HardMediumSoftScore::of_medium(1))
11
+ .named("Assign Room")
12
+ }
13
+
14
+ #[cfg(test)]
15
+ mod tests {
16
+ use super::*;
17
+ use crate::domain::{Room, Timeslot, Weekday};
18
+ use chrono::NaiveTime;
19
+
20
+ fn time(hour: u32) -> NaiveTime {
21
+ NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
22
+ }
23
+
24
+ fn plan_with_room_assignment(room_idx: Option<usize>) -> Plan {
25
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))];
26
+ let rooms = vec![Room::new(0, "Room A")];
27
+ let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)];
28
+ lessons[0].room_idx = room_idx;
29
+ Plan::new(timeslots, vec![], vec![], lessons, rooms)
30
+ }
31
+
32
+ fn evaluate_only_assign_room(plan: &Plan) -> HardMediumSoftScore {
33
+ let constraint_set = (constraint(),);
34
+ constraint_set.evaluate_all(plan)
35
+ }
36
+
37
+ #[test]
38
+ fn penalizes_unassigned_room_at_medium_level() {
39
+ let plan = plan_with_room_assignment(None);
40
+ let score = evaluate_only_assign_room(&plan);
41
+
42
+ assert_eq!(score, HardMediumSoftScore::of_medium(-1));
43
+ }
44
+
45
+ #[test]
46
+ fn assigned_room_has_zero_score() {
47
+ let plan = plan_with_room_assignment(Some(0));
48
+ let score = evaluate_only_assign_room(&plan);
49
+
50
+ assert_eq!(score, HardMediumSoftScore::ZERO);
51
+ }
52
+ }
src/constraints/assign_timeslot.rs ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// MEDIUM: Every lesson should receive a timeslot.
6
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
7
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
8
+ .for_each(Plan::lessons())
9
+ .filter(|lesson: &Lesson| lesson.timeslot_idx.is_none())
10
+ .penalize(|_: &Lesson| HardMediumSoftScore::of_medium(1))
11
+ .named("Assign Timeslot")
12
+ }
13
+
14
+ #[cfg(test)]
15
+ mod tests {
16
+ use super::*;
17
+ use crate::domain::{Room, Timeslot, Weekday};
18
+ use chrono::NaiveTime;
19
+
20
+ fn time(hour: u32) -> NaiveTime {
21
+ NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
22
+ }
23
+
24
+ fn plan_with_timeslot_assignment(timeslot_idx: Option<usize>) -> Plan {
25
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))];
26
+ let rooms = vec![Room::new(0, "Room A")];
27
+ let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)];
28
+ lessons[0].timeslot_idx = timeslot_idx;
29
+ Plan::new(timeslots, vec![], vec![], lessons, rooms)
30
+ }
31
+
32
+ fn evaluate_only_assign_timeslot(plan: &Plan) -> HardMediumSoftScore {
33
+ let constraint_set = (constraint(),);
34
+ constraint_set.evaluate_all(plan)
35
+ }
36
+
37
+ #[test]
38
+ fn penalizes_unassigned_timeslot_at_medium_level() {
39
+ let plan = plan_with_timeslot_assignment(None);
40
+ let score = evaluate_only_assign_timeslot(&plan);
41
+
42
+ assert_eq!(score, HardMediumSoftScore::of_medium(-1));
43
+ }
44
+
45
+ #[test]
46
+ fn assigned_timeslot_has_zero_score() {
47
+ let plan = plan_with_timeslot_assignment(Some(0));
48
+ let score = evaluate_only_assign_timeslot(&plan);
49
+
50
+ assert_eq!(score, HardMediumSoftScore::ZERO);
51
+ }
52
+ }
src/constraints/group_availability.rs ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Group, Lesson, Plan};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// HARD: Cohorts can only attend lessons in slots where they are available.
6
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
7
+ use solverforge::stream::joiner::equal_bi;
8
+
9
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
10
+ .for_each(Plan::lessons())
11
+ .join((
12
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::groups()),
13
+ equal_bi(
14
+ |lesson: &Lesson| lesson.group_idx,
15
+ |group: &Group| group.index,
16
+ ),
17
+ ))
18
+ .filter(|lesson: &Lesson, group: &Group| {
19
+ lesson.timeslot_idx.is_some_and(|timeslot_idx| {
20
+ !group
21
+ .availability
22
+ .get(timeslot_idx)
23
+ .copied()
24
+ .unwrap_or(false)
25
+ })
26
+ })
27
+ .penalize(hard_weight(|_: &Lesson, _: &Group| {
28
+ HardMediumSoftScore::of_hard(1)
29
+ }))
30
+ .named("Group Availability")
31
+ }
32
+
33
+ #[cfg(test)]
34
+ mod tests {
35
+ use super::*;
36
+ use crate::domain::{Room, Timeslot, Weekday};
37
+ use chrono::NaiveTime;
38
+ use solverforge::ConstraintSet;
39
+
40
+ fn time(hour: u32) -> NaiveTime {
41
+ NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
42
+ }
43
+
44
+ fn score_for_group_availability(available: bool, assigned: bool) -> HardMediumSoftScore {
45
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))];
46
+ let groups = vec![Group::new(0, "Group A", 30, vec![available])];
47
+ let rooms = vec![Room::new(0, "Room A")];
48
+ let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)];
49
+ if assigned {
50
+ lessons[0].timeslot_idx = Some(0);
51
+ lessons[0].room_idx = Some(0);
52
+ }
53
+ let plan = Plan::new(timeslots, vec![], groups, lessons, rooms);
54
+
55
+ (constraint(),).evaluate_all(&plan)
56
+ }
57
+
58
+ #[test]
59
+ fn penalizes_assigned_unavailable_group() {
60
+ assert_eq!(
61
+ score_for_group_availability(false, true),
62
+ HardMediumSoftScore::of_hard(-1)
63
+ );
64
+ }
65
+
66
+ #[test]
67
+ fn ignores_available_or_unassigned_group_slots() {
68
+ assert_eq!(
69
+ score_for_group_availability(true, true),
70
+ HardMediumSoftScore::ZERO
71
+ );
72
+ assert_eq!(
73
+ score_for_group_availability(false, false),
74
+ HardMediumSoftScore::ZERO
75
+ );
76
+ }
77
+ }
src/constraints/late_lesson.rs ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan, Timeslot};
2
+ use chrono::{NaiveTime, Timelike};
3
+ use solverforge::prelude::*;
4
+ use solverforge::IncrementalConstraint;
5
+
6
+ /// SOFT: Prefer lessons before the late afternoon.
7
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
8
+ use solverforge::stream::joiner::equal_bi;
9
+
10
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
11
+ .for_each(Plan::lessons())
12
+ .join((
13
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::timeslots()),
14
+ equal_bi(
15
+ |lesson: &Lesson| lesson.timeslot_idx,
16
+ |timeslot: &Timeslot| Some(timeslot.index),
17
+ ),
18
+ ))
19
+ .filter(|_lesson: &Lesson, timeslot: &Timeslot| is_late_slot(timeslot.start_time))
20
+ .penalize(|_: &Lesson, _: &Timeslot| HardMediumSoftScore::of_soft(1))
21
+ .named("Avoid Late Lessons")
22
+ }
23
+
24
+ fn is_late_slot(start_time: NaiveTime) -> bool {
25
+ start_time.hour() >= 15
26
+ }
27
+
28
+ #[cfg(test)]
29
+ mod tests {
30
+ use super::*;
31
+ use crate::domain::{Room, Weekday};
32
+ use solverforge::ConstraintSet;
33
+
34
+ fn time(hour: u32) -> NaiveTime {
35
+ NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
36
+ }
37
+
38
+ fn score_for_start_hour(hour: u32, assigned: bool) -> HardMediumSoftScore {
39
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(hour), time(hour + 1))];
40
+ let rooms = vec![Room::new(0, "Room A")];
41
+ let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)];
42
+ if assigned {
43
+ lessons[0].timeslot_idx = Some(0);
44
+ lessons[0].room_idx = Some(0);
45
+ }
46
+ let plan = Plan::new(timeslots, vec![], vec![], lessons, rooms);
47
+
48
+ (constraint(),).evaluate_all(&plan)
49
+ }
50
+
51
+ #[test]
52
+ fn penalizes_late_assigned_lessons() {
53
+ assert_eq!(
54
+ score_for_start_hour(15, true),
55
+ HardMediumSoftScore::of_soft(-1)
56
+ );
57
+ }
58
+
59
+ #[test]
60
+ fn ignores_early_or_unassigned_lessons() {
61
+ assert_eq!(score_for_start_hour(14, true), HardMediumSoftScore::ZERO);
62
+ assert_eq!(score_for_start_hour(15, false), HardMediumSoftScore::ZERO);
63
+ }
64
+ }
src/constraints/mod.rs ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #![cfg_attr(rustfmt, rustfmt_skip)]
2
+ //! Constraint assembly for lesson timetabling.
3
+ //!
4
+ //! Each child module owns one named timetable rule. `create_constraints()`
5
+ //! lists them in the order beginners should read the score analysis: assignment
6
+ //! completeness first, hard feasibility next, soft timetable quality last.
7
+
8
+ use crate::domain::Plan;
9
+ use solverforge::prelude::*;
10
+
11
+ pub use self::assemble::create_constraints;
12
+
13
+ // @solverforge:begin constraint-modules
14
+ mod assign_room;
15
+ mod assign_timeslot;
16
+ mod group_availability;
17
+ mod late_lesson;
18
+ mod no_group_conflict;
19
+ mod no_room_conflict;
20
+ mod no_teacher_conflict;
21
+ mod repeated_subject_day;
22
+ mod room_capacity;
23
+ mod room_kind;
24
+ mod teacher_availability;
25
+ // @solverforge:end constraint-modules
26
+
27
+ mod assemble {
28
+ use super::*;
29
+
30
+ /// Collects the full scoring model used by `Plan`.
31
+ pub fn create_constraints() -> impl ConstraintSet<Plan, HardMediumSoftScore> {
32
+ // @solverforge:begin constraint-calls
33
+ (
34
+ assign_timeslot::constraint(),
35
+ assign_room::constraint(),
36
+ teacher_availability::constraint(),
37
+ group_availability::constraint(),
38
+ room_kind::constraint(),
39
+ room_capacity::constraint(),
40
+ no_group_conflict::constraint(),
41
+ no_teacher_conflict::constraint(),
42
+ no_room_conflict::constraint(),
43
+ late_lesson::constraint(),
44
+ repeated_subject_day::constraint(),
45
+ )
46
+ // @solverforge:end constraint-calls
47
+ }
48
+ }
src/constraints/no_group_conflict.rs ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan, Timeslot, Weekday};
2
+ use chrono::NaiveTime;
3
+ use solverforge::prelude::*;
4
+ use solverforge::IncrementalConstraint;
5
+
6
+ /// HARD: No two lessons for the same group can overlap in time.
7
+ struct AssignedLessonSlot {
8
+ lesson_index: usize,
9
+ group_idx: usize,
10
+ day_of_week: Weekday,
11
+ start_time: NaiveTime,
12
+ end_time: NaiveTime,
13
+ }
14
+
15
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
16
+ use solverforge::stream::joiner::{equal, equal_bi};
17
+
18
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
19
+ .for_each(Plan::lessons())
20
+ // First attach the assigned timeslot to each lesson. Unassigned
21
+ // lessons do not join here, so this rule does not duplicate the medium
22
+ // assignment penalty.
23
+ .join((
24
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::timeslots()),
25
+ equal_bi(
26
+ |lesson: &Lesson| lesson.timeslot_idx,
27
+ |timeslot: &Timeslot| Some(timeslot.index),
28
+ ),
29
+ ))
30
+ // Reduce each joined row to the fields needed to detect a cohort
31
+ // collision. This keeps the later pairwise join small and readable.
32
+ .project(|lesson: &Lesson, timeslot: &Timeslot| AssignedLessonSlot {
33
+ lesson_index: lesson.index,
34
+ group_idx: lesson.group_idx,
35
+ day_of_week: timeslot.day_of_week,
36
+ start_time: timeslot.start_time,
37
+ end_time: timeslot.end_time,
38
+ })
39
+ .join(equal(|row: &AssignedLessonSlot| row.group_idx))
40
+ // `lesson_index < b.lesson_index` avoids scoring the same conflicting
41
+ // pair twice. The strict time comparisons implement ordinary interval
42
+ // overlap, so a lesson ending at 10:00 does not conflict with one
43
+ // starting at 10:00.
44
+ .filter(|a: &AssignedLessonSlot, b: &AssignedLessonSlot| {
45
+ a.lesson_index < b.lesson_index
46
+ && a.day_of_week == b.day_of_week
47
+ && a.start_time < b.end_time
48
+ && b.start_time < a.end_time
49
+ })
50
+ .penalize(hard_weight(
51
+ |_: &AssignedLessonSlot, _: &AssignedLessonSlot| HardMediumSoftScore::of_hard(1),
52
+ ))
53
+ .named("No Group Conflict")
54
+ }
55
+
56
+ #[cfg(test)]
57
+ mod tests {
58
+ use crate::domain::Group;
59
+
60
+ use super::*;
61
+
62
+ fn time(hour: u32, min: u32) -> NaiveTime {
63
+ NaiveTime::from_hms_opt(hour, min, 0).unwrap()
64
+ }
65
+
66
+ // Helper that only runs the No Group Conflict constraint
67
+ fn evaluate_only_group_conflict(plan: &Plan) -> HardMediumSoftScore {
68
+ let constraint_set = (constraint(),);
69
+ constraint_set.evaluate_all(plan)
70
+ }
71
+
72
+ #[test]
73
+ fn detects_group_conflict_same_timeslot() {
74
+ let groups = vec![Group::new(0, "Group A", 30, [true; 10])];
75
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))];
76
+ let teachers = vec![];
77
+ let rooms = vec![];
78
+ // Same group, no teacher assignment to avoid teacher conflict, no room assignment to avoid room conflict
79
+ let mut lessons = vec![
80
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
81
+ Lesson::new(1, "Physics".to_string(), 0, None, 120),
82
+ ];
83
+ lessons[0].timeslot_idx = Some(0);
84
+ lessons[1].timeslot_idx = Some(0);
85
+
86
+ let plan = Plan::new(timeslots, teachers, groups, lessons, rooms);
87
+ let score = evaluate_only_group_conflict(&plan);
88
+
89
+ assert_eq!(
90
+ score.hard(),
91
+ -1,
92
+ "Expected hard score -1 for group conflict"
93
+ );
94
+ }
95
+
96
+ #[test]
97
+ fn detects_group_conflict_overlapping_timeslots() {
98
+ let groups = vec![Group::new(0, "Group A", 30, [true; 10])];
99
+ let timeslots = vec![
100
+ Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)),
101
+ Timeslot::new(1, Weekday::Mon, time(9, 0), time(11, 0)),
102
+ ];
103
+ let teachers = vec![];
104
+ let rooms = vec![];
105
+ let mut lessons = vec![
106
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
107
+ Lesson::new(1, "Physics".to_string(), 0, None, 120),
108
+ ];
109
+ lessons[0].timeslot_idx = Some(0);
110
+ lessons[1].timeslot_idx = Some(1);
111
+
112
+ let plan = Plan::new(timeslots, teachers, groups, lessons, rooms);
113
+ let score = evaluate_only_group_conflict(&plan);
114
+
115
+ assert_eq!(
116
+ score.hard(),
117
+ -1,
118
+ "Expected hard score -1 for overlapping group conflict"
119
+ );
120
+ }
121
+
122
+ #[test]
123
+ fn no_conflict_different_groups() {
124
+ let groups = vec![
125
+ Group::new(0, "Group A", 30, [true; 10]),
126
+ Group::new(1, "Group B", 30, [true; 10]),
127
+ ];
128
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))];
129
+ let teachers = vec![];
130
+ let rooms = vec![];
131
+ let mut lessons = vec![
132
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
133
+ Lesson::new(1, "Physics".to_string(), 1, None, 120),
134
+ ];
135
+ lessons[0].timeslot_idx = Some(0);
136
+ lessons[1].timeslot_idx = Some(0);
137
+
138
+ let plan = Plan::new(timeslots, teachers, groups, lessons, rooms);
139
+ let score = evaluate_only_group_conflict(&plan);
140
+
141
+ assert_eq!(
142
+ score.hard(),
143
+ 0,
144
+ "Expected hard score 0 for different groups"
145
+ );
146
+ }
147
+
148
+ #[test]
149
+ fn no_conflict_non_overlapping_timeslots() {
150
+ let groups = vec![Group::new(0, "Group A", 30, [true; 10])];
151
+ let timeslots = vec![
152
+ Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)),
153
+ Timeslot::new(1, Weekday::Mon, time(10, 0), time(12, 0)),
154
+ ];
155
+ let teachers = vec![];
156
+ let rooms = vec![];
157
+ let mut lessons = vec![
158
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
159
+ Lesson::new(1, "Physics".to_string(), 0, None, 120),
160
+ ];
161
+ lessons[0].timeslot_idx = Some(0);
162
+ lessons[1].timeslot_idx = Some(1);
163
+
164
+ let plan = Plan::new(timeslots, teachers, groups, lessons, rooms);
165
+ let score = evaluate_only_group_conflict(&plan);
166
+
167
+ assert_eq!(
168
+ score.hard(),
169
+ 0,
170
+ "Expected hard score 0 for non-overlapping timeslots"
171
+ );
172
+ }
173
+ }
src/constraints/no_room_conflict.rs ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan, Timeslot, Weekday};
2
+ use chrono::NaiveTime;
3
+ use solverforge::prelude::*;
4
+ use solverforge::IncrementalConstraint;
5
+
6
+ /// HARD: No two lessons in the same room can overlap in time.
7
+ struct AssignedLessonSlot {
8
+ lesson_index: usize,
9
+ room_idx: usize,
10
+ day_of_week: Weekday,
11
+ start_time: NaiveTime,
12
+ end_time: NaiveTime,
13
+ }
14
+
15
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
16
+ use solverforge::stream::joiner::{equal, equal_bi};
17
+
18
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
19
+ .for_each(Plan::lessons())
20
+ // First attach the assigned timeslot to each lesson. Unassigned
21
+ // lessons do not join here, so this rule does not duplicate the medium
22
+ // assignment penalty.
23
+ .join((
24
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::timeslots()),
25
+ equal_bi(
26
+ |lesson: &Lesson| lesson.timeslot_idx,
27
+ |timeslot: &Timeslot| Some(timeslot.index),
28
+ ),
29
+ ))
30
+ // Reduce each joined row to the fields needed to detect a room
31
+ // collision. This keeps the later pairwise join small and readable.
32
+ .project(|lesson: &Lesson, timeslot: &Timeslot| AssignedLessonSlot {
33
+ lesson_index: lesson.index,
34
+ room_idx: lesson.room_idx.unwrap_or(usize::MAX),
35
+ day_of_week: timeslot.day_of_week,
36
+ start_time: timeslot.start_time,
37
+ end_time: timeslot.end_time,
38
+ })
39
+ .join(equal(|row: &AssignedLessonSlot| row.room_idx))
40
+ // `lesson_index < b.lesson_index` avoids scoring the same conflicting
41
+ // pair twice. The strict time comparisons implement ordinary interval
42
+ // overlap, so a lesson ending at 10:00 does not conflict with one
43
+ // starting at 10:00.
44
+ .filter(|a: &AssignedLessonSlot, b: &AssignedLessonSlot| {
45
+ a.lesson_index < b.lesson_index
46
+ && a.day_of_week == b.day_of_week
47
+ && a.start_time < b.end_time
48
+ && b.start_time < a.end_time
49
+ })
50
+ .penalize(hard_weight(
51
+ |_: &AssignedLessonSlot, _: &AssignedLessonSlot| HardMediumSoftScore::of_hard(1),
52
+ ))
53
+ .named("No Room Conflict")
54
+ }
55
+
56
+ #[cfg(test)]
57
+ mod tests {
58
+ use crate::domain::Room;
59
+
60
+ use super::*;
61
+
62
+ fn time(hour: u32, min: u32) -> NaiveTime {
63
+ NaiveTime::from_hms_opt(hour, min, 0).unwrap()
64
+ }
65
+
66
+ // Helper that only runs the No Room Conflict constraint
67
+ fn evaluate_only_room_conflict(plan: &Plan) -> HardMediumSoftScore {
68
+ let constraint_set = (constraint(),);
69
+ constraint_set.evaluate_all(plan)
70
+ }
71
+
72
+ #[test]
73
+ fn detects_room_conflict_same_timeslot() {
74
+ let rooms = vec![Room::new(0, "Room A")];
75
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))];
76
+ let teachers = vec![];
77
+ // Different groups to avoid group conflict, no teacher assignment to avoid teacher conflict
78
+ let mut lessons = vec![
79
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
80
+ Lesson::new(1, "Physics".to_string(), 1, None, 120),
81
+ ];
82
+ lessons[0].timeslot_idx = Some(0);
83
+ lessons[0].room_idx = Some(0);
84
+ lessons[1].timeslot_idx = Some(0);
85
+ lessons[1].room_idx = Some(0);
86
+
87
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
88
+ let score = evaluate_only_room_conflict(&plan);
89
+
90
+ assert_eq!(score.hard(), -1, "Expected hard score -1 for room conflict");
91
+ }
92
+
93
+ #[test]
94
+ fn detects_room_conflict_overlapping_timeslots() {
95
+ let rooms = vec![Room::new(0, "Room A")];
96
+ let timeslots = vec![
97
+ Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)),
98
+ Timeslot::new(1, Weekday::Mon, time(9, 0), time(11, 0)),
99
+ ];
100
+ let teachers = vec![];
101
+ let mut lessons = vec![
102
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
103
+ Lesson::new(1, "Physics".to_string(), 1, None, 120),
104
+ ];
105
+ lessons[0].timeslot_idx = Some(0);
106
+ lessons[0].room_idx = Some(0);
107
+ lessons[1].timeslot_idx = Some(1);
108
+ lessons[1].room_idx = Some(0);
109
+
110
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
111
+ let score = evaluate_only_room_conflict(&plan);
112
+
113
+ assert_eq!(
114
+ score.hard(),
115
+ -1,
116
+ "Expected hard score -1 for overlapping room conflict"
117
+ );
118
+ }
119
+
120
+ #[test]
121
+ fn no_conflict_different_rooms() {
122
+ let rooms = vec![Room::new(0, "Room A"), Room::new(1, "Room B")];
123
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))];
124
+ let teachers = vec![];
125
+ let mut lessons = vec![
126
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
127
+ Lesson::new(1, "Physics".to_string(), 0, None, 120),
128
+ ];
129
+ lessons[0].timeslot_idx = Some(0);
130
+ lessons[0].room_idx = Some(0);
131
+ lessons[1].timeslot_idx = Some(0);
132
+ lessons[1].room_idx = Some(1);
133
+
134
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
135
+ let score = evaluate_only_room_conflict(&plan);
136
+
137
+ assert_eq!(score.hard(), 0, "Expected hard score 0 for different rooms");
138
+ }
139
+
140
+ #[test]
141
+ fn no_conflict_non_overlapping_timeslots() {
142
+ let rooms = vec![Room::new(0, "Room A")];
143
+ let timeslots = vec![
144
+ Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)),
145
+ Timeslot::new(1, Weekday::Mon, time(10, 0), time(12, 0)),
146
+ ];
147
+ let teachers = vec![];
148
+ let mut lessons = vec![
149
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
150
+ Lesson::new(1, "Physics".to_string(), 0, None, 120),
151
+ ];
152
+ lessons[0].timeslot_idx = Some(0);
153
+ lessons[0].room_idx = Some(0);
154
+ lessons[1].timeslot_idx = Some(1);
155
+ lessons[1].room_idx = Some(0);
156
+
157
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
158
+ let score = evaluate_only_room_conflict(&plan);
159
+
160
+ assert_eq!(
161
+ score.hard(),
162
+ 0,
163
+ "Expected hard score 0 for non-overlapping timeslots"
164
+ );
165
+ }
166
+
167
+ #[test]
168
+ fn no_conflict_unassigned_room() {
169
+ let rooms = vec![Room::new(0, "Room A")];
170
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))];
171
+ let teachers = vec![];
172
+ let mut lessons = vec![
173
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
174
+ Lesson::new(1, "Physics".to_string(), 1, None, 120),
175
+ ];
176
+ lessons[0].timeslot_idx = Some(0);
177
+ lessons[0].room_idx = None; // Unassigned
178
+ lessons[1].timeslot_idx = Some(0);
179
+ lessons[1].room_idx = Some(0);
180
+
181
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
182
+ let score = evaluate_only_room_conflict(&plan);
183
+
184
+ assert_eq!(score.hard(), 0, "Expected hard score 0 for unassigned room");
185
+ }
186
+ }
src/constraints/no_teacher_conflict.rs ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan, Timeslot, Weekday};
2
+ use chrono::NaiveTime;
3
+ use solverforge::prelude::*;
4
+ use solverforge::IncrementalConstraint;
5
+
6
+ /// HARD: No two lessons with the same teacher can overlap in time.
7
+ struct AssignedLessonSlot {
8
+ lesson_index: usize,
9
+ teacher_idx: usize,
10
+ day_of_week: Weekday,
11
+ start_time: NaiveTime,
12
+ end_time: NaiveTime,
13
+ }
14
+
15
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
16
+ use solverforge::stream::joiner::{equal, equal_bi};
17
+
18
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
19
+ .for_each(Plan::lessons())
20
+ // First attach the assigned timeslot to each lesson. Unassigned
21
+ // lessons do not join here, so this rule does not duplicate the medium
22
+ // assignment penalty.
23
+ .join((
24
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::timeslots()),
25
+ equal_bi(
26
+ |lesson: &Lesson| lesson.timeslot_idx,
27
+ |timeslot: &Timeslot| Some(timeslot.index),
28
+ ),
29
+ ))
30
+ // Reduce each joined row to the fields needed to detect a teacher
31
+ // collision. This keeps the later pairwise join small and readable.
32
+ .project(|lesson: &Lesson, timeslot: &Timeslot| AssignedLessonSlot {
33
+ lesson_index: lesson.index,
34
+ teacher_idx: lesson.teacher_idx.unwrap_or(usize::MAX),
35
+ day_of_week: timeslot.day_of_week,
36
+ start_time: timeslot.start_time,
37
+ end_time: timeslot.end_time,
38
+ })
39
+ .join(equal(|row: &AssignedLessonSlot| row.teacher_idx))
40
+ // `lesson_index < b.lesson_index` avoids scoring the same conflicting
41
+ // pair twice. The strict time comparisons implement ordinary interval
42
+ // overlap, so a lesson ending at 10:00 does not conflict with one
43
+ // starting at 10:00.
44
+ .filter(|a: &AssignedLessonSlot, b: &AssignedLessonSlot| {
45
+ a.lesson_index < b.lesson_index
46
+ && a.day_of_week == b.day_of_week
47
+ && a.start_time < b.end_time
48
+ && b.start_time < a.end_time
49
+ })
50
+ .penalize(hard_weight(
51
+ |_: &AssignedLessonSlot, _: &AssignedLessonSlot| HardMediumSoftScore::of_hard(1),
52
+ ))
53
+ .named("No Teacher Conflict")
54
+ }
55
+
56
+ #[cfg(test)]
57
+ mod tests {
58
+ use crate::domain::Teacher;
59
+
60
+ use super::*;
61
+
62
+ fn time(hour: u32, min: u32) -> NaiveTime {
63
+ NaiveTime::from_hms_opt(hour, min, 0).unwrap()
64
+ }
65
+
66
+ // Helper that only runs the No Teacher Conflict constraint
67
+ fn evaluate_only_teacher_conflict(plan: &Plan) -> HardMediumSoftScore {
68
+ // Create a constraint set with only the teacher conflict constraint
69
+ // Using a tuple to implement ConstraintSet
70
+ let constraint_set = (constraint(),);
71
+ constraint_set.evaluate_all(plan)
72
+ }
73
+
74
+ #[test]
75
+ fn detects_teacher_conflict_same_timeslot() {
76
+ let teachers = vec![Teacher::new(0, "Prof A", [true; 10])];
77
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))];
78
+ let rooms = vec![];
79
+ // Different groups to avoid group conflict, no room assignment to avoid room conflict
80
+ let mut lessons = vec![
81
+ Lesson::new(0, "Math".to_string(), 0, Some(0), 120),
82
+ Lesson::new(1, "Physics".to_string(), 1, Some(0), 120),
83
+ ];
84
+ lessons[0].timeslot_idx = Some(0);
85
+ lessons[1].timeslot_idx = Some(0);
86
+
87
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
88
+ let score = evaluate_only_teacher_conflict(&plan);
89
+
90
+ assert_eq!(
91
+ score.hard(),
92
+ -1,
93
+ "Expected hard score -1 for teacher conflict"
94
+ );
95
+ }
96
+
97
+ #[test]
98
+ fn detects_teacher_conflict_overlapping_timeslots() {
99
+ let teachers = vec![Teacher::new(0, "Prof A", [true; 10])];
100
+ let timeslots = vec![
101
+ Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)),
102
+ Timeslot::new(1, Weekday::Mon, time(9, 0), time(11, 0)),
103
+ ];
104
+ let rooms = vec![];
105
+ let mut lessons = vec![
106
+ Lesson::new(0, "Math".to_string(), 0, Some(0), 120),
107
+ Lesson::new(1, "Physics".to_string(), 1, Some(0), 120),
108
+ ];
109
+ lessons[0].timeslot_idx = Some(0);
110
+ lessons[1].timeslot_idx = Some(1);
111
+
112
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
113
+ let score = evaluate_only_teacher_conflict(&plan);
114
+
115
+ assert_eq!(
116
+ score.hard(),
117
+ -1,
118
+ "Expected hard score -1 for overlapping teacher conflict"
119
+ );
120
+ }
121
+
122
+ #[test]
123
+ fn no_conflict_different_teachers() {
124
+ let teachers = vec![
125
+ Teacher::new(0, "Prof A", [true; 10]),
126
+ Teacher::new(1, "Prof B", [true; 10]),
127
+ ];
128
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))];
129
+ let rooms = vec![];
130
+ let mut lessons = vec![
131
+ Lesson::new(0, "Math".to_string(), 0, Some(0), 120),
132
+ Lesson::new(1, "Physics".to_string(), 0, Some(1), 120),
133
+ ];
134
+ lessons[0].timeslot_idx = Some(0);
135
+ lessons[1].timeslot_idx = Some(0);
136
+
137
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
138
+ let score = evaluate_only_teacher_conflict(&plan);
139
+
140
+ assert_eq!(
141
+ score.hard(),
142
+ 0,
143
+ "Expected hard score 0 for different teachers"
144
+ );
145
+ }
146
+
147
+ #[test]
148
+ fn no_conflict_non_overlapping_timeslots() {
149
+ let teachers = vec![Teacher::new(0, "Prof A", [true; 10])];
150
+ let timeslots = vec![
151
+ Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)),
152
+ Timeslot::new(1, Weekday::Mon, time(10, 0), time(12, 0)),
153
+ ];
154
+ let rooms = vec![];
155
+ let mut lessons = vec![
156
+ Lesson::new(0, "Math".to_string(), 0, Some(0), 120),
157
+ Lesson::new(1, "Physics".to_string(), 0, Some(0), 120),
158
+ ];
159
+ lessons[0].timeslot_idx = Some(0);
160
+ lessons[1].timeslot_idx = Some(1);
161
+
162
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
163
+ let score = evaluate_only_teacher_conflict(&plan);
164
+
165
+ assert_eq!(
166
+ score.hard(),
167
+ 0,
168
+ "Expected hard score 0 for non-overlapping timeslots"
169
+ );
170
+ }
171
+
172
+ #[test]
173
+ fn no_conflict_unassigned_teacher() {
174
+ let teachers = vec![Teacher::new(0, "Prof A", [true; 10])];
175
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))];
176
+ let rooms = vec![];
177
+ let mut lessons = vec![
178
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
179
+ Lesson::new(1, "Physics".to_string(), 1, Some(0), 120),
180
+ ];
181
+ lessons[0].timeslot_idx = Some(0);
182
+ lessons[1].timeslot_idx = Some(0);
183
+
184
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
185
+ let score = evaluate_only_teacher_conflict(&plan);
186
+
187
+ assert_eq!(
188
+ score.hard(),
189
+ 0,
190
+ "Expected hard score 0 for unassigned teacher"
191
+ );
192
+ }
193
+ }
src/constraints/repeated_subject_day.rs ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan, Timeslot, Weekday};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// SOFT: Prefer not to schedule the same subject twice in one day for a cohort.
6
+ struct LessonDay {
7
+ lesson_index: usize,
8
+ group_idx: usize,
9
+ subject: String,
10
+ day_of_week: Weekday,
11
+ }
12
+
13
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
14
+ use solverforge::stream::joiner::{equal, equal_bi};
15
+
16
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
17
+ .for_each(Plan::lessons())
18
+ .join((
19
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::timeslots()),
20
+ equal_bi(
21
+ |lesson: &Lesson| lesson.timeslot_idx,
22
+ |timeslot: &Timeslot| Some(timeslot.index),
23
+ ),
24
+ ))
25
+ .project(|lesson: &Lesson, timeslot: &Timeslot| LessonDay {
26
+ lesson_index: lesson.index,
27
+ group_idx: lesson.group_idx,
28
+ subject: lesson.subject.clone(),
29
+ day_of_week: timeslot.day_of_week,
30
+ })
31
+ .join(equal(|row: &LessonDay| row.group_idx))
32
+ .filter(|a: &LessonDay, b: &LessonDay| {
33
+ a.lesson_index < b.lesson_index
34
+ && a.day_of_week == b.day_of_week
35
+ && a.subject == b.subject
36
+ })
37
+ .penalize(|_: &LessonDay, _: &LessonDay| HardMediumSoftScore::of_soft(1))
38
+ .named("Avoid Repeated Subject Day")
39
+ }
40
+
41
+ #[cfg(test)]
42
+ mod tests {
43
+ use super::*;
44
+ use crate::domain::Room;
45
+ use chrono::NaiveTime;
46
+ use solverforge::ConstraintSet;
47
+
48
+ fn time(hour: u32) -> NaiveTime {
49
+ NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
50
+ }
51
+
52
+ fn score_for_subject_days(second_day: Weekday) -> HardMediumSoftScore {
53
+ let timeslots = vec![
54
+ Timeslot::new(0, Weekday::Mon, time(8), time(9)),
55
+ Timeslot::new(1, second_day, time(9), time(10)),
56
+ ];
57
+ let rooms = vec![Room::new(0, "Room A")];
58
+ let mut lessons = vec![
59
+ Lesson::new(0, "Math".to_string(), 0, None, 60),
60
+ Lesson::new(1, "Math".to_string(), 0, None, 60),
61
+ ];
62
+ lessons[0].timeslot_idx = Some(0);
63
+ lessons[0].room_idx = Some(0);
64
+ lessons[1].timeslot_idx = Some(1);
65
+ lessons[1].room_idx = Some(0);
66
+ let plan = Plan::new(timeslots, vec![], vec![], lessons, rooms);
67
+
68
+ (constraint(),).evaluate_all(&plan)
69
+ }
70
+
71
+ #[test]
72
+ fn penalizes_repeated_subject_on_same_day() {
73
+ assert_eq!(
74
+ score_for_subject_days(Weekday::Mon),
75
+ HardMediumSoftScore::of_soft(-1)
76
+ );
77
+ }
78
+
79
+ #[test]
80
+ fn ignores_same_subject_on_different_days() {
81
+ assert_eq!(
82
+ score_for_subject_days(Weekday::Tue),
83
+ HardMediumSoftScore::ZERO
84
+ );
85
+ }
86
+ }
src/constraints/room_capacity.rs ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan, Room};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// HARD: A room must be large enough for the cohort assigned to it.
6
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
7
+ use solverforge::stream::joiner::equal_bi;
8
+
9
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
10
+ .for_each(Plan::lessons())
11
+ .join((
12
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::rooms()),
13
+ equal_bi(
14
+ |lesson: &Lesson| lesson.room_idx,
15
+ |room: &Room| Some(room.index),
16
+ ),
17
+ ))
18
+ .filter(|lesson: &Lesson, room: &Room| lesson.student_count > room.capacity)
19
+ .penalize(hard_weight(|_: &Lesson, _: &Room| {
20
+ HardMediumSoftScore::of_hard(1)
21
+ }))
22
+ .named("Room Capacity")
23
+ }
24
+
25
+ #[cfg(test)]
26
+ mod tests {
27
+ use super::*;
28
+ use crate::domain::{Group, RoomKind, Timeslot, Weekday};
29
+ use chrono::NaiveTime;
30
+ use solverforge::ConstraintSet;
31
+
32
+ fn time(hour: u32) -> NaiveTime {
33
+ NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
34
+ }
35
+
36
+ fn score_for_room_capacity(capacity: usize, assigned: bool) -> HardMediumSoftScore {
37
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))];
38
+ let groups = vec![Group::new(0, "Group A", 30, vec![true])];
39
+ let rooms = vec![Room::with_kind_capacity(
40
+ 0,
41
+ "Room A",
42
+ RoomKind::Lecture,
43
+ capacity,
44
+ )];
45
+ let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)];
46
+ if assigned {
47
+ lessons[0].timeslot_idx = Some(0);
48
+ lessons[0].room_idx = Some(0);
49
+ }
50
+ let plan = Plan::new(timeslots, vec![], groups, lessons, rooms);
51
+
52
+ (constraint(),).evaluate_all(&plan)
53
+ }
54
+
55
+ #[test]
56
+ fn penalizes_room_under_capacity() {
57
+ assert_eq!(
58
+ score_for_room_capacity(24, true),
59
+ HardMediumSoftScore::of_hard(-1)
60
+ );
61
+ }
62
+
63
+ #[test]
64
+ fn ignores_sufficient_or_unassigned_room_capacity() {
65
+ assert_eq!(
66
+ score_for_room_capacity(30, true),
67
+ HardMediumSoftScore::ZERO
68
+ );
69
+ assert_eq!(
70
+ score_for_room_capacity(24, false),
71
+ HardMediumSoftScore::ZERO
72
+ );
73
+ }
74
+ }
src/constraints/room_kind.rs ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan, Room};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// SOFT: Prefer assigning lessons to rooms that support the subject.
6
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
7
+ use solverforge::stream::joiner::equal_bi;
8
+
9
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
10
+ .for_each(Plan::lessons())
11
+ .join((
12
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::rooms()),
13
+ equal_bi(
14
+ |lesson: &Lesson| lesson.room_idx,
15
+ |room: &Room| Some(room.index),
16
+ ),
17
+ ))
18
+ .filter(|lesson: &Lesson, room: &Room| lesson.required_room_kind != room.kind)
19
+ .penalize(|_: &Lesson, _: &Room| HardMediumSoftScore::of_soft(1))
20
+ .named("Room Kind")
21
+ }
22
+
23
+ #[cfg(test)]
24
+ mod tests {
25
+ use super::*;
26
+ use crate::domain::{RoomKind, Timeslot, Weekday};
27
+ use chrono::NaiveTime;
28
+ use solverforge::ConstraintSet;
29
+
30
+ fn time(hour: u32) -> NaiveTime {
31
+ NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
32
+ }
33
+
34
+ fn score_for_room_kind(room_kind: RoomKind, assigned: bool) -> HardMediumSoftScore {
35
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))];
36
+ let rooms = vec![Room::with_kind_capacity(0, "Room A", room_kind, 40)];
37
+ let mut lessons = vec![Lesson::with_required_room_kind(
38
+ 0,
39
+ "Chemistry".to_string(),
40
+ 0,
41
+ None,
42
+ 60,
43
+ RoomKind::Lab,
44
+ )];
45
+ if assigned {
46
+ lessons[0].timeslot_idx = Some(0);
47
+ lessons[0].room_idx = Some(0);
48
+ }
49
+ let plan = Plan::new(timeslots, vec![], vec![], lessons, rooms);
50
+
51
+ (constraint(),).evaluate_all(&plan)
52
+ }
53
+
54
+ #[test]
55
+ fn penalizes_room_kind_mismatch() {
56
+ assert_eq!(
57
+ score_for_room_kind(RoomKind::Lecture, true),
58
+ HardMediumSoftScore::of_soft(-1)
59
+ );
60
+ }
61
+
62
+ #[test]
63
+ fn ignores_matching_or_unassigned_rooms() {
64
+ assert_eq!(
65
+ score_for_room_kind(RoomKind::Lab, true),
66
+ HardMediumSoftScore::ZERO
67
+ );
68
+ assert_eq!(
69
+ score_for_room_kind(RoomKind::Lecture, false),
70
+ HardMediumSoftScore::ZERO
71
+ );
72
+ }
73
+ }
src/constraints/teacher_availability.rs ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{Lesson, Plan, Teacher};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// HARD: Teachers can only teach in slots where they are available.
6
+ pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> {
7
+ use solverforge::stream::joiner::equal_bi;
8
+
9
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new()
10
+ .for_each(Plan::lessons())
11
+ .join((
12
+ ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::teachers()),
13
+ equal_bi(
14
+ |lesson: &Lesson| lesson.teacher_idx,
15
+ |teacher: &Teacher| Some(teacher.index),
16
+ ),
17
+ ))
18
+ .filter(|lesson: &Lesson, teacher: &Teacher| {
19
+ lesson.timeslot_idx.is_some_and(|timeslot_idx| {
20
+ !teacher
21
+ .availability
22
+ .get(timeslot_idx)
23
+ .copied()
24
+ .unwrap_or(false)
25
+ })
26
+ })
27
+ .penalize(hard_weight(|_: &Lesson, _: &Teacher| {
28
+ HardMediumSoftScore::of_hard(1)
29
+ }))
30
+ .named("Teacher Availability")
31
+ }
32
+
33
+ #[cfg(test)]
34
+ mod tests {
35
+ use super::*;
36
+ use crate::domain::{Room, Timeslot, Weekday};
37
+ use chrono::NaiveTime;
38
+ use solverforge::ConstraintSet;
39
+
40
+ fn time(hour: u32) -> NaiveTime {
41
+ NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
42
+ }
43
+
44
+ fn score_for_teacher_availability(available: bool, assigned: bool) -> HardMediumSoftScore {
45
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))];
46
+ let teachers = vec![Teacher::new(0, "Teacher A", vec![available])];
47
+ let rooms = vec![Room::new(0, "Room A")];
48
+ let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, Some(0), 60)];
49
+ if assigned {
50
+ lessons[0].timeslot_idx = Some(0);
51
+ lessons[0].room_idx = Some(0);
52
+ }
53
+ let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms);
54
+
55
+ (constraint(),).evaluate_all(&plan)
56
+ }
57
+
58
+ #[test]
59
+ fn penalizes_assigned_unavailable_teacher() {
60
+ assert_eq!(
61
+ score_for_teacher_availability(false, true),
62
+ HardMediumSoftScore::of_hard(-1)
63
+ );
64
+ }
65
+
66
+ #[test]
67
+ fn ignores_available_or_unassigned_teacher_slots() {
68
+ assert_eq!(
69
+ score_for_teacher_availability(true, true),
70
+ HardMediumSoftScore::ZERO
71
+ );
72
+ assert_eq!(
73
+ score_for_teacher_availability(false, false),
74
+ HardMediumSoftScore::ZERO
75
+ );
76
+ }
77
+ }
src/data/data_seed.rs ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Public demo-data surface for the school timetable example.
2
+ //!
3
+ //! Keep this file intentionally thin. The rest of the application imports
4
+ //! `crate::data::{generate, available_demo_data, DemoData}` as a stable boundary, so
5
+ //! the detailed dataset design lives in sibling modules where it can evolve
6
+ //! without making the top-level data surface noisy.
7
+
8
+ mod entrypoints;
9
+ mod groups;
10
+ mod large;
11
+ mod lessons;
12
+ mod rooms;
13
+ #[cfg(test)]
14
+ mod solve_tests;
15
+ mod teachers;
16
+ mod timeslots;
17
+ mod vocabulary;
18
+
19
+ pub use entrypoints::{available_demo_data, default_demo_data, generate, DemoData};
src/data/data_seed/entrypoints.rs ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::str::FromStr;
2
+
3
+ use crate::domain::Plan;
4
+
5
+ use super::large::generate_large;
6
+
7
+ /// Public demo-data identifiers exposed through the HTTP API.
8
+ ///
9
+ /// The university app currently ships one serious benchmark instance rather than a
10
+ /// menu of toy presets, so the surface stays explicit instead of pretending that
11
+ /// multiple sizes exist when they do not.
12
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
13
+ pub enum DemoData {
14
+ Large,
15
+ }
16
+
17
+ impl DemoData {
18
+ /// Returns the canonical uppercase id used by the HTTP API.
19
+ pub fn id(self) -> &'static str {
20
+ match self {
21
+ DemoData::Large => "LARGE",
22
+ }
23
+ }
24
+
25
+ /// Returns the default demo data.
26
+ pub fn default_demo_data() -> Self {
27
+ DemoData::Large
28
+ }
29
+
30
+ /// Returns all available demo data identifiers.
31
+ pub fn available_demo_data() -> &'static [Self] {
32
+ &[DemoData::Large]
33
+ }
34
+ }
35
+
36
+ /// Returns the default demo data.
37
+ pub fn default_demo_data() -> DemoData {
38
+ DemoData::Large
39
+ }
40
+
41
+ /// Returns the list of available demo data identifiers.
42
+ pub fn available_demo_data() -> &'static [DemoData] {
43
+ DemoData::available_demo_data()
44
+ }
45
+
46
+ impl FromStr for DemoData {
47
+ type Err = ();
48
+
49
+ /// Parses the case-insensitive demo id exposed over HTTP.
50
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
51
+ match s.to_uppercase().as_str() {
52
+ "LARGE" => Ok(DemoData::Large),
53
+ _ => Err(()),
54
+ }
55
+ }
56
+ }
57
+
58
+ /// Generates the requested demo dataset.
59
+ ///
60
+ /// Dispatch stays here so callers see the supported public variants in one
61
+ /// place, while the dataset assembly itself remains hidden in the per-instance
62
+ /// modules.
63
+ pub fn generate(demo: DemoData) -> Plan {
64
+ match demo {
65
+ DemoData::Large => generate_large(),
66
+ }
67
+ }
68
+
69
+ #[cfg(test)]
70
+ mod tests {
71
+ use super::*;
72
+ use crate::constraints::create_constraints;
73
+ use solverforge::{ConstraintSet, HardMediumSoftScore};
74
+
75
+ #[test]
76
+ fn test_generate_large() {
77
+ let plan = generate(DemoData::Large);
78
+ assert_eq!(plan.timeslots.len(), 40);
79
+ assert_eq!(plan.teachers.len(), 20);
80
+ assert_eq!(plan.groups.len(), 12);
81
+ assert_eq!(plan.lessons.len(), 300);
82
+ assert_eq!(plan.rooms.len(), 10);
83
+ assert!(plan
84
+ .lessons
85
+ .iter()
86
+ .all(|lesson| lesson.timeslot_idx.is_none()));
87
+ assert!(plan.lessons.iter().all(|lesson| lesson.room_idx.is_none()));
88
+ }
89
+
90
+ #[test]
91
+ fn test_generate_large_initial_score() {
92
+ let plan = generate(DemoData::Large);
93
+ let score = create_constraints().evaluate_all(&plan);
94
+
95
+ assert_eq!(score, HardMediumSoftScore::of_medium(-600));
96
+ }
97
+
98
+ #[test]
99
+ fn test_demo_data_from_str() {
100
+ assert_eq!("LARGE".parse::<DemoData>().ok(), Some(DemoData::Large));
101
+ assert_eq!("large".parse::<DemoData>().ok(), Some(DemoData::Large));
102
+ assert_eq!("INVALID".parse::<DemoData>().ok(), None);
103
+ }
104
+
105
+ #[test]
106
+ fn test_demo_data_id() {
107
+ assert_eq!(DemoData::Large.id(), "LARGE");
108
+ }
109
+
110
+ #[test]
111
+ fn test_default_demo_data() {
112
+ assert_eq!(default_demo_data(), DemoData::Large);
113
+ assert_eq!(DemoData::default_demo_data(), DemoData::Large);
114
+ }
115
+
116
+ #[test]
117
+ fn test_available_demo_data() {
118
+ assert_eq!(available_demo_data(), &[DemoData::Large]);
119
+ assert_eq!(DemoData::available_demo_data(), &[DemoData::Large]);
120
+ }
121
+ }
src/data/data_seed/groups.rs ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::Group;
2
+
3
+ use super::vocabulary::{group_specs, weekly_availability};
4
+
5
+ /// Builds groups with varied availability patterns.
6
+ ///
7
+ /// Creates groups with different availability to simulate
8
+ /// real-world constraints where groups may have restricted schedules.
9
+ pub(super) fn build_groups(count: usize) -> Vec<Group> {
10
+ group_specs()
11
+ .iter()
12
+ .take(count)
13
+ .enumerate()
14
+ .map(|(index, spec)| {
15
+ let unavailable = [
16
+ index % 8,
17
+ 8 + ((index + 2) % 8),
18
+ 16 + ((index + 4) % 8),
19
+ 24 + ((index + 6) % 8),
20
+ 32 + ((index + 1) % 8),
21
+ ];
22
+
23
+ Group::new(
24
+ index,
25
+ spec.name,
26
+ spec.student_count,
27
+ weekly_availability(&unavailable),
28
+ )
29
+ })
30
+ .collect()
31
+ }
32
+
33
+ #[cfg(test)]
34
+ mod tests {
35
+ use super::*;
36
+
37
+ #[test]
38
+ fn test_build_groups_with_varied_availability() {
39
+ let groups = build_groups(12);
40
+ assert_eq!(groups.len(), 12);
41
+ assert_eq!(groups[0].name, "Cohort 01");
42
+ assert_eq!(groups[5].student_count, 34);
43
+ assert_eq!(groups[0].availability.len(), 40);
44
+ assert!(!groups[0].availability[0]);
45
+ assert!(!groups[0].availability[10]);
46
+ assert!(!groups[0].availability[20]);
47
+ assert!(!groups[0].availability[30]);
48
+ assert!(!groups[0].availability[33]);
49
+ assert_eq!(
50
+ groups[0]
51
+ .availability
52
+ .iter()
53
+ .filter(|available| !**available)
54
+ .count(),
55
+ 5
56
+ );
57
+ }
58
+ }
src/data/data_seed/large.rs ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::OnceLock;
2
+
3
+ use crate::domain::Plan;
4
+
5
+ use super::groups::build_groups;
6
+ use super::lessons::build_lessons;
7
+ use super::rooms::build_rooms;
8
+ use super::teachers::build_teachers;
9
+ use super::timeslots::build_timeslots;
10
+ use super::vocabulary::{GROUP_COUNT, ROOM_COUNT, TIMESLOT_COUNT};
11
+
12
+ /// Materializes the canonical university benchmark dataset.
13
+ ///
14
+ /// We cache the built plan because demo data is immutable and deterministic.
15
+ /// Reusing the same constructed instance avoids paying generator cost on every
16
+ /// API request while still returning an owned `Plan` to each caller.
17
+ pub fn generate_large() -> Plan {
18
+ static SCHEDULE: OnceLock<Plan> = OnceLock::new();
19
+ SCHEDULE.get_or_init(build_large_schedule).clone()
20
+ }
21
+
22
+ /// Builds the large university timetable instance from scratch.
23
+ ///
24
+ /// This generates a substantial dataset suitable for benchmarking:
25
+ /// - 40 timeslots (full week: Monday-Friday, 8:00-18:00, skipping lunch 12:00-14:00)
26
+ /// - 20 teachers with subject-specific availability
27
+ /// - 12 groups
28
+ /// - 300 lessons (25 per group based on subject hours allocation)
29
+ /// - 10 typed rooms
30
+ fn build_large_schedule() -> Plan {
31
+ // Full week timeslots: 5 days * 8 slots per day = 40 timeslots
32
+ let timeslots = build_timeslots(TIMESLOT_COUNT);
33
+
34
+ let teachers = build_teachers();
35
+
36
+ let groups = build_groups(GROUP_COUNT);
37
+
38
+ let lessons = build_lessons(GROUP_COUNT);
39
+
40
+ let rooms = build_rooms(ROOM_COUNT);
41
+
42
+ Plan::new(timeslots, teachers, groups, lessons, rooms)
43
+ }
src/data/data_seed/lessons.rs ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::Lesson;
2
+
3
+ use super::vocabulary::{group_specs, subjects, teacher_index, LESSON_DURATION_MINUTES};
4
+
5
+ /// Builds lessons for all groups based on subject configuration.
6
+ ///
7
+ /// For each group, creates lessons for all subjects with their allocated hours_per_week.
8
+ /// Each lesson is assigned to a group, a teacher, and a required room kind.
9
+ ///
10
+ /// Teacher assignment: for a given subject, group 0 gets teachers[0], group 1 gets teachers[1],
11
+ /// etc. (wrapping around if there are more groups than teachers for that subject).
12
+ ///
13
+ /// Total lessons = GROUP_COUNT * sum(hours_per_week for all subjects).
14
+ /// With 12 groups and current config: 12 * 25 = 300 lessons.
15
+ pub(super) fn build_lessons(group_count: usize) -> Vec<Lesson> {
16
+ let mut lessons = Vec::new();
17
+ let mut lesson_index = 0;
18
+
19
+ // For each group
20
+ for group_idx in 0..group_count {
21
+ let student_count = group_specs()[group_idx].student_count;
22
+
23
+ // For each subject, create hours_per_week lessons
24
+ for subject_config in subjects() {
25
+ // Determine which teacher to use for this subject and group
26
+ // Use round-robin: group_idx % number_of_teachers_for_subject
27
+ for lesson_num in 0..subject_config.hours_per_week {
28
+ let teacher_name = subject_config.teachers
29
+ [(group_idx + lesson_num) % subject_config.teachers.len()];
30
+ let teacher_idx = teacher_index(teacher_name);
31
+
32
+ lessons.push(Lesson::with_details(
33
+ lesson_index,
34
+ subject_config.name.to_string(),
35
+ group_idx,
36
+ student_count,
37
+ Some(teacher_idx),
38
+ LESSON_DURATION_MINUTES,
39
+ subject_config.room_kind,
40
+ ));
41
+ lesson_index += 1;
42
+ }
43
+ }
44
+ }
45
+
46
+ lessons
47
+ }
48
+
49
+ #[cfg(test)]
50
+ mod tests {
51
+ use super::*;
52
+
53
+ #[test]
54
+ fn test_build_lessons() {
55
+ let lessons = build_lessons(12);
56
+ assert_eq!(lessons.len(), 300);
57
+ }
58
+
59
+ #[test]
60
+ fn test_all_groups_have_all_subjects() {
61
+ let lessons = build_lessons(12);
62
+ let subject_names: Vec<&str> = subjects().iter().map(|subject| subject.name).collect();
63
+
64
+ // Check each group has lessons for all subjects
65
+ for group_idx in 0..12 {
66
+ let group_lessons: Vec<_> = lessons
67
+ .iter()
68
+ .filter(|l| l.group_idx == group_idx)
69
+ .collect();
70
+
71
+ for subject_name in &subject_names {
72
+ let subject_lessons: Vec<_> = group_lessons
73
+ .iter()
74
+ .filter(|l| l.subject == *subject_name)
75
+ .collect();
76
+
77
+ let expected_count = subjects()
78
+ .iter()
79
+ .find(|subject| subject.name == *subject_name)
80
+ .unwrap()
81
+ .hours_per_week;
82
+ assert_eq!(
83
+ subject_lessons.len(),
84
+ expected_count,
85
+ "Group {} should have {} lessons for {}",
86
+ group_idx,
87
+ expected_count,
88
+ subject_name
89
+ );
90
+ }
91
+ }
92
+ }
93
+
94
+ #[test]
95
+ fn test_teacher_assignment() {
96
+ let lessons = build_lessons(12);
97
+
98
+ // The generated catalog has four English teachers, assigned round-robin.
99
+ let english_lessons: Vec<_> = lessons.iter().filter(|l| l.subject == "English").collect();
100
+
101
+ assert_eq!(english_lessons.len(), 48);
102
+
103
+ // Check teacher assignment pattern for English
104
+ for group_idx in 0..12 {
105
+ let group_english: Vec<_> = english_lessons
106
+ .iter()
107
+ .filter(|l| l.group_idx == group_idx)
108
+ .collect();
109
+
110
+ let expected_teacher_idx = teacher_index(
111
+ [
112
+ "Jane Austen",
113
+ "William Shakespeare",
114
+ "Chinua Achebe",
115
+ "Mary Shelley",
116
+ ][group_idx % 4],
117
+ );
118
+
119
+ assert_eq!(group_english[0].teacher_idx, Some(expected_teacher_idx));
120
+ }
121
+ }
122
+
123
+ #[test]
124
+ fn test_lesson_duration() {
125
+ let lessons = build_lessons(12);
126
+ for lesson in &lessons {
127
+ assert_eq!(lesson.duration, LESSON_DURATION_MINUTES);
128
+ }
129
+ }
130
+
131
+ #[test]
132
+ fn test_subjects_are_classic_uk_school() {
133
+ let subject_names: Vec<&str> = subjects().iter().map(|subject| subject.name).collect();
134
+
135
+ assert!(subject_names.contains(&"Mathematics"));
136
+ assert!(subject_names.contains(&"Physics"));
137
+ assert!(subject_names.contains(&"Chemistry"));
138
+ assert!(subject_names.contains(&"Biology"));
139
+ assert!(subject_names.contains(&"Computer Science"));
140
+ assert!(subject_names.contains(&"English"));
141
+ assert!(subject_names.contains(&"History"));
142
+ assert!(subject_names.contains(&"Geography"));
143
+ assert!(subject_names.contains(&"French"));
144
+ assert!(subject_names.contains(&"German"));
145
+ assert_eq!(subject_names.len(), 10);
146
+ }
147
+
148
+ #[test]
149
+ fn test_total_lessons_per_group() {
150
+ let lessons = build_lessons(12);
151
+ // Each group should have 25 lessons (sum of hours_per_week)
152
+ for group_idx in 0..12 {
153
+ let group_lessons: Vec<_> = lessons
154
+ .iter()
155
+ .filter(|l| l.group_idx == group_idx)
156
+ .collect();
157
+ assert_eq!(group_lessons.len(), 25);
158
+ }
159
+ }
160
+
161
+ #[test]
162
+ fn test_all_lessons_have_teacher() {
163
+ let lessons = build_lessons(12);
164
+ for lesson in &lessons {
165
+ assert!(
166
+ lesson.teacher_idx.is_some(),
167
+ "Lesson {} has no teacher",
168
+ lesson.id
169
+ );
170
+ }
171
+ }
172
+ }
src/data/data_seed/rooms.rs ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::Room;
2
+
3
+ use super::vocabulary::room_specs;
4
+
5
+ pub(super) fn build_rooms(count: usize) -> Vec<Room> {
6
+ room_specs()
7
+ .iter()
8
+ .take(count)
9
+ .enumerate()
10
+ .map(|(index, spec)| Room::with_kind_capacity(index, spec.name, spec.kind, spec.capacity))
11
+ .collect()
12
+ }
13
+
14
+ #[cfg(test)]
15
+ mod tests {
16
+ use super::*;
17
+
18
+ #[test]
19
+ fn test_build_rooms() {
20
+ let rooms = build_rooms(10);
21
+ assert_eq!(rooms.len(), 10);
22
+ assert_eq!(rooms[0].id, "room-0");
23
+ assert_eq!(rooms[0].name, "Auditorium A");
24
+ assert_eq!(rooms[7].name, "Computer Lab");
25
+ }
26
+ }
src/data/data_seed/solve_tests.rs ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use solverforge::{ConstraintSet, SolverEvent, SolverManager};
2
+ use std::collections::BTreeSet;
3
+
4
+ use super::{generate, DemoData};
5
+ use crate::domain::Plan;
6
+
7
+ // Static manager — must be 'static for retained job execution.
8
+ static MANAGER: SolverManager<Plan> = SolverManager::new();
9
+
10
+ fn schedule() -> Plan {
11
+ generate(DemoData::Large)
12
+ }
13
+
14
+ /// Slow end-to-end acceptance test for the large dataset.
15
+ ///
16
+ /// This verifies that the solver starts from an unassigned schedule, reaches a
17
+ /// hard/medium-feasible timetable, and keeps a visible soft optimization score.
18
+ #[test]
19
+ #[ignore = "slow acceptance test for the large dataset"]
20
+ fn large_demo_solves_to_feasible_progressing_schedule() {
21
+ let plan = schedule();
22
+ let initial_score = crate::constraints::create_constraints().evaluate_all(&plan);
23
+ assert_eq!(
24
+ initial_score,
25
+ solverforge::HardMediumSoftScore::of_medium(-600),
26
+ "The generated demo must start unassigned, not already solved"
27
+ );
28
+
29
+ let (job_id, mut receiver) = MANAGER.solve(plan).expect("job should start");
30
+ let mut completed_score = None;
31
+ let mut completed_solution = None;
32
+ let mut observed_scores = Vec::new();
33
+
34
+ while let Some(event) = receiver.blocking_recv() {
35
+ if let Some(score) = event.metadata().current_score {
36
+ observed_scores.push(score);
37
+ }
38
+
39
+ match event {
40
+ SolverEvent::Completed { solution, .. } => {
41
+ completed_score = solution.score;
42
+ completed_solution = Some(solution);
43
+ break;
44
+ }
45
+ SolverEvent::Failed { error, .. } => {
46
+ panic!("demo solve failed unexpectedly: {error}");
47
+ }
48
+ _ => {}
49
+ }
50
+ }
51
+
52
+ let score = completed_score.expect("expected a completed score");
53
+ let solution = completed_solution.expect("expected a completed solution");
54
+
55
+ // The best solution must satisfy both the hard feasibility rules and the
56
+ // medium-level assignment requirements while retaining soft optimization
57
+ // pressure that lets the UI show continued score movement.
58
+ assert_eq!(
59
+ score.hard(),
60
+ 0,
61
+ "Expected hard-feasible solution, but got: {}",
62
+ score
63
+ );
64
+ assert_eq!(
65
+ score.medium(),
66
+ 0,
67
+ "Expected all lessons assigned, but got: {}",
68
+ score
69
+ );
70
+ assert!(
71
+ score.soft() < 0,
72
+ "Expected remaining soft penalties for realistic timetable quality, got: {}",
73
+ score
74
+ );
75
+ assert!(
76
+ score.medium() > initial_score.medium(),
77
+ "Expected terminal score to improve from the unassigned medium penalty"
78
+ );
79
+ assert!(
80
+ observed_scores.contains(&initial_score),
81
+ "Expected event stream to expose the unassigned initial score"
82
+ );
83
+ assert!(
84
+ observed_scores
85
+ .iter()
86
+ .any(|score| score.hard() == 0 && score.medium() == 0 && score.soft() < 0),
87
+ "Expected event stream to expose a feasible soft-scored timetable"
88
+ );
89
+
90
+ let lesson_count = solution.lessons.len();
91
+ let assigned_timeslots = solution
92
+ .lessons
93
+ .iter()
94
+ .filter(|l| l.timeslot_idx.is_some())
95
+ .count();
96
+ let assigned_rooms = solution
97
+ .lessons
98
+ .iter()
99
+ .filter(|l| l.room_idx.is_some())
100
+ .count();
101
+
102
+ assert_eq!(
103
+ assigned_timeslots, lesson_count,
104
+ "Every lesson must have a timeslot assignment"
105
+ );
106
+ assert_eq!(
107
+ assigned_rooms, lesson_count,
108
+ "Every lesson must have a room assignment"
109
+ );
110
+
111
+ let distinct_timeslots = solution
112
+ .lessons
113
+ .iter()
114
+ .filter_map(|lesson| lesson.timeslot_idx)
115
+ .collect::<BTreeSet<_>>()
116
+ .len();
117
+ let distinct_rooms = solution
118
+ .lessons
119
+ .iter()
120
+ .filter_map(|lesson| lesson.room_idx)
121
+ .collect::<BTreeSet<_>>()
122
+ .len();
123
+
124
+ assert!(
125
+ distinct_timeslots > 1,
126
+ "The solved timetable must not collapse every lesson into one timeslot"
127
+ );
128
+ assert!(
129
+ distinct_rooms > 1,
130
+ "The solved timetable must not collapse every lesson into one room"
131
+ );
132
+
133
+ let constraints = crate::constraints::create_constraints();
134
+ let hard_or_medium_constraints: Vec<_> = constraints
135
+ .evaluate_detailed(&solution)
136
+ .into_iter()
137
+ .filter(|analysis| analysis.score.hard() != 0 || analysis.score.medium() != 0)
138
+ .map(|analysis| format!("{}={}", analysis.constraint_ref.name, analysis.score))
139
+ .collect();
140
+ assert!(
141
+ hard_or_medium_constraints.is_empty(),
142
+ "Expected all hard/medium constraints to score zero, got: {}",
143
+ hard_or_medium_constraints.join(", ")
144
+ );
145
+
146
+ eprintln!(
147
+ "Solution: {} lessons, {} timeslots assigned, {} rooms assigned, score {}",
148
+ lesson_count, assigned_timeslots, assigned_rooms, score
149
+ );
150
+
151
+ MANAGER.delete(job_id).expect("delete completed job");
152
+ }
src/data/data_seed/teachers.rs ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::Teacher;
2
+
3
+ use super::vocabulary::{teacher_names, weekly_availability};
4
+
5
+ /// Builds teachers with varied availability patterns.
6
+ /// Each teacher is created from the teacher_names() list with their full name.
7
+ pub(super) fn build_teachers() -> Vec<Teacher> {
8
+ teacher_names()
9
+ .iter()
10
+ .enumerate()
11
+ .map(|(index, name)| {
12
+ let unavailable = [
13
+ index % 8,
14
+ 8 + ((index * 3) % 8),
15
+ 16 + ((index * 5) % 8),
16
+ 32 + ((index * 7) % 8),
17
+ ];
18
+
19
+ Teacher::new(index, name.to_string(), weekly_availability(&unavailable))
20
+ })
21
+ .collect()
22
+ }
23
+
24
+ #[cfg(test)]
25
+ mod tests {
26
+ use super::*;
27
+
28
+ #[test]
29
+ fn test_build_teachers() {
30
+ let teachers = build_teachers();
31
+ let teacher_names_list = teacher_names();
32
+ assert_eq!(teachers.len(), teacher_names_list.len());
33
+
34
+ for (index, teacher) in teachers.iter().enumerate() {
35
+ assert_eq!(teacher.availability.len(), 40);
36
+ assert!(!teacher.availability[index % 8]);
37
+ assert_eq!(
38
+ teacher
39
+ .availability
40
+ .iter()
41
+ .filter(|available| !**available)
42
+ .count(),
43
+ 4
44
+ );
45
+ }
46
+ }
47
+
48
+ #[test]
49
+ fn test_teacher_names() {
50
+ let teachers = build_teachers();
51
+ assert_eq!(teachers.len(), 20);
52
+ assert_eq!(teachers[0].name, "Jane Austen");
53
+ assert_eq!(teachers[1].name, "William Shakespeare");
54
+ assert_eq!(teachers[15].name, "Ada Lovelace");
55
+ assert_eq!(teachers[16].name, "Alan Turing");
56
+ assert_eq!(teachers[19].name, "Alexander von Humboldt");
57
+ }
58
+
59
+ #[test]
60
+ fn test_teacher_names_from_subjects() {
61
+ let names = teacher_names();
62
+ assert_eq!(names.len(), 20);
63
+ assert!(names.contains(&"Marie Curie"));
64
+ assert!(names.contains(&"Alan Turing"));
65
+ assert!(names.contains(&"William Shakespeare"));
66
+ }
67
+ }
src/data/data_seed/timeslots.rs ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use chrono::NaiveTime;
2
+
3
+ use crate::domain::{Timeslot, Weekday};
4
+
5
+ use super::vocabulary::{
6
+ DAY_END_HOUR, DAY_START_HOUR, LESSON_DURATION_HOURS, LUNCH_BREAK_END, LUNCH_BREAK_START,
7
+ };
8
+
9
+ /// Builds a deterministic set of timeslots for the schedule.
10
+ ///
11
+ /// Creates 1-hour timeslots for each day of the week (Monday-Friday):
12
+ /// - 8:00-9:00
13
+ /// - 9:00-10:00
14
+ /// - 10:00-11:00
15
+ /// - 11:00-12:00
16
+ /// - 14:00-15:00 (lunch break 12:00-14:00 skipped)
17
+ /// - 15:00-16:00
18
+ /// - 16:00-17:00
19
+ /// - 17:00-18:00
20
+ ///
21
+ /// Total: 40 timeslots (5 days * 8 slots per day)
22
+ pub(super) fn build_timeslots(count: usize) -> Vec<Timeslot> {
23
+ // If count is 0 or very small, use the simple sequential approach
24
+ if count <= 7 {
25
+ return build_simple_timeslots(count);
26
+ }
27
+
28
+ // Build 1-hour timeslots for a full week, skipping lunch break (12:00-14:00)
29
+ let mut timeslots = Vec::with_capacity(count.min(40));
30
+ let mut index = 0;
31
+
32
+ for day in [
33
+ Weekday::Mon,
34
+ Weekday::Tue,
35
+ Weekday::Wed,
36
+ Weekday::Thu,
37
+ Weekday::Fri,
38
+ ] {
39
+ if index >= count {
40
+ break;
41
+ }
42
+
43
+ // Generate hour slots from 8:00 to 18:00, skipping 12:00-14:00
44
+ for hour in DAY_START_HOUR..DAY_END_HOUR {
45
+ if index >= count {
46
+ break;
47
+ }
48
+
49
+ // Skip lunch break: no timeslots from 12:00 to 14:00
50
+ if (LUNCH_BREAK_START..LUNCH_BREAK_END).contains(&hour) {
51
+ continue;
52
+ }
53
+
54
+ let start_hour = hour;
55
+ let end_hour = start_hour + LESSON_DURATION_HOURS;
56
+
57
+ timeslots.push(Timeslot::new(
58
+ index,
59
+ day,
60
+ NaiveTime::from_hms_opt(start_hour, 0, 0).unwrap(),
61
+ NaiveTime::from_hms_opt(end_hour, 0, 0).unwrap(),
62
+ ));
63
+ index += 1;
64
+ }
65
+ }
66
+
67
+ timeslots
68
+ }
69
+
70
+ /// Builds simple sequential timeslots with default values.
71
+ /// Used for small test cases or when count is very small.
72
+ pub(super) fn build_simple_timeslots(count: usize) -> Vec<Timeslot> {
73
+ (0..count)
74
+ .map(|index| Timeslot {
75
+ id: format!("timeslot-{index}"),
76
+ index,
77
+ day_of_week: Default::default(),
78
+ start_time: Default::default(),
79
+ end_time: Default::default(),
80
+ })
81
+ .collect()
82
+ }
83
+
84
+ #[cfg(test)]
85
+ mod tests {
86
+ use chrono::Timelike;
87
+
88
+ use super::*;
89
+
90
+ #[test]
91
+ fn test_build_simple_timeslots() {
92
+ let timeslots = build_simple_timeslots(3);
93
+ assert_eq!(timeslots.len(), 3);
94
+ assert_eq!(timeslots[0].id, "timeslot-0");
95
+ assert_eq!(timeslots[1].id, "timeslot-1");
96
+ assert_eq!(timeslots[2].id, "timeslot-2");
97
+ }
98
+
99
+ #[test]
100
+ fn test_build_timeslots_full_week() {
101
+ let timeslots = build_timeslots(40);
102
+ assert_eq!(timeslots.len(), 40);
103
+
104
+ // Check first timeslot is Monday 8:00-9:00
105
+ assert_eq!(timeslots[0].index, 0);
106
+ assert_eq!(timeslots[0].day_of_week, Weekday::Mon);
107
+ assert_eq!(
108
+ timeslots[0].start_time,
109
+ NaiveTime::from_hms_opt(8, 0, 0).unwrap()
110
+ );
111
+ assert_eq!(
112
+ timeslots[0].end_time,
113
+ NaiveTime::from_hms_opt(9, 0, 0).unwrap()
114
+ );
115
+
116
+ // Check Monday morning slots (8:00-9:00, 9:00-10:00, 10:00-11:00, 11:00-12:00)
117
+ assert_eq!(
118
+ timeslots[0].start_time,
119
+ NaiveTime::from_hms_opt(8, 0, 0).unwrap()
120
+ );
121
+ assert_eq!(
122
+ timeslots[1].start_time,
123
+ NaiveTime::from_hms_opt(9, 0, 0).unwrap()
124
+ );
125
+ assert_eq!(
126
+ timeslots[2].start_time,
127
+ NaiveTime::from_hms_opt(10, 0, 0).unwrap()
128
+ );
129
+ assert_eq!(
130
+ timeslots[3].start_time,
131
+ NaiveTime::from_hms_opt(11, 0, 0).unwrap()
132
+ );
133
+
134
+ // Check that 12:00-13:00 and 13:00-14:00 are skipped (no slot starts at 12 or 13)
135
+ // After 11:00-12:00 (index 3), next should be 14:00-15:00
136
+ assert_eq!(
137
+ timeslots[4].start_time,
138
+ NaiveTime::from_hms_opt(14, 0, 0).unwrap()
139
+ );
140
+ assert_eq!(
141
+ timeslots[4].end_time,
142
+ NaiveTime::from_hms_opt(15, 0, 0).unwrap()
143
+ );
144
+
145
+ // Check Monday afternoon slots (14:00-15:00, 15:00-16:00, 16:00-17:00, 17:00-18:00)
146
+ assert_eq!(
147
+ timeslots[4].start_time,
148
+ NaiveTime::from_hms_opt(14, 0, 0).unwrap()
149
+ );
150
+ assert_eq!(
151
+ timeslots[5].start_time,
152
+ NaiveTime::from_hms_opt(15, 0, 0).unwrap()
153
+ );
154
+ assert_eq!(
155
+ timeslots[6].start_time,
156
+ NaiveTime::from_hms_opt(16, 0, 0).unwrap()
157
+ );
158
+ assert_eq!(
159
+ timeslots[7].start_time,
160
+ NaiveTime::from_hms_opt(17, 0, 0).unwrap()
161
+ );
162
+
163
+ // Check first timeslot of Tuesday (index 8)
164
+ assert_eq!(timeslots[8].index, 8);
165
+ assert_eq!(timeslots[8].day_of_week, Weekday::Tue);
166
+ assert_eq!(
167
+ timeslots[8].start_time,
168
+ NaiveTime::from_hms_opt(8, 0, 0).unwrap()
169
+ );
170
+ }
171
+
172
+ #[test]
173
+ fn test_build_timeslots_partial() {
174
+ let timeslots = build_timeslots(10);
175
+ assert_eq!(timeslots.len(), 10);
176
+ // Should have Monday (8 slots) + first 2 of Tuesday = 10 timeslots
177
+ assert_eq!(timeslots[0].day_of_week, Weekday::Mon);
178
+ assert_eq!(timeslots[8].day_of_week, Weekday::Tue);
179
+ assert_eq!(
180
+ timeslots[8].start_time,
181
+ NaiveTime::from_hms_opt(8, 0, 0).unwrap()
182
+ );
183
+ assert_eq!(
184
+ timeslots[9].start_time,
185
+ NaiveTime::from_hms_opt(9, 0, 0).unwrap()
186
+ );
187
+ }
188
+
189
+ #[test]
190
+ fn test_no_lunch_break_slots() {
191
+ let timeslots = build_timeslots(40);
192
+ // Verify no timeslot starts at 12:00 or 13:00
193
+ for timeslot in &timeslots {
194
+ let start_hour = timeslot.start_time.hour();
195
+ assert_ne!(start_hour, 12, "No timeslot should start at 12:00");
196
+ assert_ne!(start_hour, 13, "No timeslot should start at 13:00");
197
+ }
198
+ }
199
+
200
+ #[test]
201
+ fn test_all_slots_are_one_hour() {
202
+ let timeslots = build_timeslots(40);
203
+ for timeslot in &timeslots {
204
+ let duration = timeslot.end_time.signed_duration_since(timeslot.start_time);
205
+ assert_eq!(duration.num_hours(), 1, "All timeslots should be 1 hour");
206
+ }
207
+ }
208
+ }
src/data/data_seed/vocabulary.rs ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Generator constants and shared university vocabulary.
2
+
3
+ use crate::domain::RoomKind;
4
+
5
+ // Timeslot configuration
6
+ pub(super) const LESSON_DURATION_HOURS: u32 = 1;
7
+ pub(super) const DAY_START_HOUR: u32 = 8;
8
+ pub(super) const DAY_END_HOUR: u32 = 18;
9
+ pub(super) const LUNCH_BREAK_START: u32 = 12;
10
+ pub(super) const LUNCH_BREAK_END: u32 = 14;
11
+
12
+ // Large dataset sizes
13
+ pub(super) const TIMESLOT_COUNT: usize = 40;
14
+ pub(super) const GROUP_COUNT: usize = 12;
15
+ pub(super) const ROOM_COUNT: usize = 10;
16
+
17
+ // Lesson configuration
18
+ pub(super) const LESSON_DURATION_MINUTES: u32 = 60;
19
+
20
+ /// Subject configuration with weekly demand, qualified teachers, and room type.
21
+ #[derive(Debug, Clone, Copy)]
22
+ pub(super) struct Subject {
23
+ pub name: &'static str,
24
+ pub hours_per_week: usize,
25
+ pub teachers: &'static [&'static str],
26
+ pub room_kind: RoomKind,
27
+ }
28
+
29
+ /// Fixed room inventory used by the canonical benchmark instance.
30
+ #[derive(Debug, Clone, Copy)]
31
+ pub(super) struct RoomSpec {
32
+ pub name: &'static str,
33
+ pub kind: RoomKind,
34
+ pub capacity: usize,
35
+ }
36
+
37
+ /// Fixed cohort inventory used by the canonical benchmark instance.
38
+ #[derive(Debug, Clone, Copy)]
39
+ pub(super) struct GroupSpec {
40
+ pub name: &'static str,
41
+ pub student_count: usize,
42
+ }
43
+
44
+ /// Ordered subject catalog.
45
+ ///
46
+ /// The weekly load is 25 lessons per cohort. With 12 cohorts this produces 300
47
+ /// unassigned lessons for the solver to place.
48
+ pub(super) fn subjects() -> &'static [Subject] {
49
+ &[
50
+ Subject {
51
+ name: "English",
52
+ hours_per_week: 4,
53
+ teachers: &[
54
+ "Jane Austen",
55
+ "William Shakespeare",
56
+ "Chinua Achebe",
57
+ "Mary Shelley",
58
+ ],
59
+ room_kind: RoomKind::Lecture,
60
+ },
61
+ Subject {
62
+ name: "Mathematics",
63
+ hours_per_week: 4,
64
+ teachers: &[
65
+ "Isaac Newton",
66
+ "Emmy Noether",
67
+ "Katherine Johnson",
68
+ "Florence Nightingale",
69
+ ],
70
+ room_kind: RoomKind::Lecture,
71
+ },
72
+ Subject {
73
+ name: "Physics",
74
+ hours_per_week: 3,
75
+ teachers: &["Marie Curie", "Albert Einstein", "Stephen Hawking"],
76
+ room_kind: RoomKind::Lab,
77
+ },
78
+ Subject {
79
+ name: "Chemistry",
80
+ hours_per_week: 3,
81
+ teachers: &["Marie Curie", "Albert Einstein", "Rosalind Franklin"],
82
+ room_kind: RoomKind::Lab,
83
+ },
84
+ Subject {
85
+ name: "Biology",
86
+ hours_per_week: 3,
87
+ teachers: &[
88
+ "Rosalind Franklin",
89
+ "Charles Darwin",
90
+ "Jane Goodall",
91
+ "Rachel Carson",
92
+ ],
93
+ room_kind: RoomKind::Lab,
94
+ },
95
+ Subject {
96
+ name: "Computer Science",
97
+ hours_per_week: 2,
98
+ teachers: &[
99
+ "Ada Lovelace",
100
+ "Alan Turing",
101
+ "Grace Hopper",
102
+ "Donald Knuth",
103
+ ],
104
+ room_kind: RoomKind::Computer,
105
+ },
106
+ Subject {
107
+ name: "History",
108
+ hours_per_week: 2,
109
+ teachers: &[
110
+ "Jane Austen",
111
+ "William Shakespeare",
112
+ "Chinua Achebe",
113
+ "Mary Shelley",
114
+ ],
115
+ room_kind: RoomKind::Lecture,
116
+ },
117
+ Subject {
118
+ name: "Geography",
119
+ hours_per_week: 2,
120
+ teachers: &[
121
+ "Charles Darwin",
122
+ "Jane Goodall",
123
+ "Rachel Carson",
124
+ "Alexander von Humboldt",
125
+ ],
126
+ room_kind: RoomKind::Lecture,
127
+ },
128
+ Subject {
129
+ name: "French",
130
+ hours_per_week: 1,
131
+ teachers: &["Chinua Achebe", "Mary Shelley"],
132
+ room_kind: RoomKind::Language,
133
+ },
134
+ Subject {
135
+ name: "German",
136
+ hours_per_week: 1,
137
+ teachers: &["William Shakespeare", "Alexander von Humboldt"],
138
+ room_kind: RoomKind::Language,
139
+ },
140
+ ]
141
+ }
142
+
143
+ /// Returns teacher names in first-use catalog order.
144
+ pub(super) fn teacher_names() -> Vec<&'static str> {
145
+ let mut names = Vec::new();
146
+ for subject in subjects() {
147
+ for teacher in subject.teachers {
148
+ if !names.contains(teacher) {
149
+ names.push(*teacher);
150
+ }
151
+ }
152
+ }
153
+ names
154
+ }
155
+
156
+ /// Returns the index of a teacher name in the generated teacher list.
157
+ pub(super) fn teacher_index(name: &str) -> usize {
158
+ teacher_names().iter().position(|&n| n == name).unwrap()
159
+ }
160
+
161
+ /// Returns the rooms available in the generated instance.
162
+ pub(super) fn room_specs() -> &'static [RoomSpec] {
163
+ &[
164
+ RoomSpec {
165
+ name: "Auditorium A",
166
+ kind: RoomKind::Lecture,
167
+ capacity: 120,
168
+ },
169
+ RoomSpec {
170
+ name: "Auditorium B",
171
+ kind: RoomKind::Lecture,
172
+ capacity: 80,
173
+ },
174
+ RoomSpec {
175
+ name: "Seminar 1",
176
+ kind: RoomKind::Lecture,
177
+ capacity: 40,
178
+ },
179
+ RoomSpec {
180
+ name: "Seminar 2",
181
+ kind: RoomKind::Lecture,
182
+ capacity: 36,
183
+ },
184
+ RoomSpec {
185
+ name: "Wet Lab 1",
186
+ kind: RoomKind::Lab,
187
+ capacity: 36,
188
+ },
189
+ RoomSpec {
190
+ name: "Wet Lab 2",
191
+ kind: RoomKind::Lab,
192
+ capacity: 36,
193
+ },
194
+ RoomSpec {
195
+ name: "Wet Lab 3",
196
+ kind: RoomKind::Lab,
197
+ capacity: 36,
198
+ },
199
+ RoomSpec {
200
+ name: "Computer Lab",
201
+ kind: RoomKind::Computer,
202
+ capacity: 36,
203
+ },
204
+ RoomSpec {
205
+ name: "Language Room A",
206
+ kind: RoomKind::Language,
207
+ capacity: 36,
208
+ },
209
+ RoomSpec {
210
+ name: "Language Room B",
211
+ kind: RoomKind::Language,
212
+ capacity: 36,
213
+ },
214
+ ]
215
+ }
216
+
217
+ /// Returns the cohorts available in the generated instance.
218
+ pub(super) fn group_specs() -> &'static [GroupSpec] {
219
+ &[
220
+ GroupSpec {
221
+ name: "Cohort 01",
222
+ student_count: 24,
223
+ },
224
+ GroupSpec {
225
+ name: "Cohort 02",
226
+ student_count: 26,
227
+ },
228
+ GroupSpec {
229
+ name: "Cohort 03",
230
+ student_count: 28,
231
+ },
232
+ GroupSpec {
233
+ name: "Cohort 04",
234
+ student_count: 30,
235
+ },
236
+ GroupSpec {
237
+ name: "Cohort 05",
238
+ student_count: 32,
239
+ },
240
+ GroupSpec {
241
+ name: "Cohort 06",
242
+ student_count: 34,
243
+ },
244
+ GroupSpec {
245
+ name: "Cohort 07",
246
+ student_count: 22,
247
+ },
248
+ GroupSpec {
249
+ name: "Cohort 08",
250
+ student_count: 25,
251
+ },
252
+ GroupSpec {
253
+ name: "Cohort 09",
254
+ student_count: 29,
255
+ },
256
+ GroupSpec {
257
+ name: "Cohort 10",
258
+ student_count: 31,
259
+ },
260
+ GroupSpec {
261
+ name: "Cohort 11",
262
+ student_count: 33,
263
+ },
264
+ GroupSpec {
265
+ name: "Cohort 12",
266
+ student_count: 27,
267
+ },
268
+ ]
269
+ }
270
+
271
+ /// Builds a 40-slot weekly availability vector with selected unavailable slots.
272
+ pub(super) fn weekly_availability(unavailable_slots: &[usize]) -> Vec<bool> {
273
+ let mut availability = vec![true; TIMESLOT_COUNT];
274
+ for slot in unavailable_slots {
275
+ if let Some(value) = availability.get_mut(*slot) {
276
+ *value = false;
277
+ }
278
+ }
279
+ availability
280
+ }
src/data/mod.rs ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ //! Stable demo-data boundary for the lesson-timetabling app.
2
+ //!
3
+ //! Other layers import from `crate::data` instead of seed-specific files. That
4
+ //! keeps demo-id parsing and timetable generation behind one small interface.
5
+
6
+ mod data_seed;
7
+
8
+ pub use data_seed::{available_demo_data, default_demo_data, generate, DemoData};
src/domain/group.rs ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use solverforge::prelude::*;
3
+
4
+ /// A student cohort that receives a weekly timetable.
5
+ #[problem_fact]
6
+ #[derive(Serialize, Deserialize)]
7
+ pub struct Group {
8
+ #[planning_id]
9
+ pub id: String,
10
+ #[serde(skip)]
11
+ pub index: usize, // the solver-facing join key
12
+ pub name: String,
13
+ pub student_count: usize,
14
+ pub availability: Vec<bool>,
15
+ }
16
+
17
+ impl Group {
18
+ pub fn new(
19
+ index: usize,
20
+ name: impl Into<String>,
21
+ student_count: usize,
22
+ availability: impl Into<Vec<bool>>,
23
+ ) -> Self {
24
+ Self {
25
+ id: format!("group-{index}"),
26
+ index,
27
+ name: name.into(),
28
+ student_count,
29
+ availability: availability.into(),
30
+ }
31
+ }
32
+ }
33
+
34
+ #[cfg(test)]
35
+ mod tests {
36
+ use super::*;
37
+
38
+ #[test]
39
+ fn test_group_construction() {
40
+ let fact = Group::new(0, "test", 32, vec![true; 40]);
41
+ assert_eq!(fact.index, 0);
42
+ assert_eq!(fact.id, "group-0");
43
+ assert_eq!(fact.name, "test");
44
+ assert_eq!(fact.student_count, 32);
45
+ let _ = &fact.id;
46
+ let _ = &fact.availability;
47
+ }
48
+ }
src/domain/lesson.rs ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use solverforge::prelude::*;
3
+
4
+ use super::RoomKind;
5
+
6
+ /// A subject meeting that the solver assigns to one timeslot and one room.
7
+ #[planning_entity]
8
+ #[derive(Serialize, Deserialize)]
9
+ pub struct Lesson {
10
+ #[planning_id]
11
+ pub id: String,
12
+ /// Dense solver-facing join key rebuilt by `Plan::rebuild_derived_fields`.
13
+ #[serde(skip)]
14
+ pub index: usize,
15
+ pub subject: String,
16
+ pub group_idx: usize,
17
+ pub student_count: usize,
18
+ pub teacher_idx: Option<usize>,
19
+ pub duration: u32,
20
+ pub required_room_kind: RoomKind,
21
+ // @solverforge:begin entity-variables
22
+ /// Scalar planning variable pointing at `Plan.timeslots`.
23
+ #[planning_variable(value_range_provider = "timeslots", allows_unassigned = false)]
24
+ pub timeslot_idx: Option<usize>,
25
+ /// Scalar planning variable pointing at `Plan.rooms`.
26
+ #[planning_variable(value_range_provider = "rooms", allows_unassigned = false)]
27
+ pub room_idx: Option<usize>,
28
+ // @solverforge:end entity-variables
29
+ }
30
+
31
+ impl Lesson {
32
+ /// Builds an unassigned lesson with the default lecture-room requirement.
33
+ pub fn new(
34
+ index: usize,
35
+ subject: String,
36
+ group_idx: usize,
37
+ teacher_idx: Option<usize>,
38
+ duration: u32,
39
+ ) -> Self {
40
+ Self::with_required_room_kind(
41
+ index,
42
+ subject,
43
+ group_idx,
44
+ teacher_idx,
45
+ duration,
46
+ RoomKind::Lecture,
47
+ )
48
+ }
49
+
50
+ /// Builds an unassigned lesson with an explicit room-kind requirement.
51
+ pub fn with_required_room_kind(
52
+ index: usize,
53
+ subject: String,
54
+ group_idx: usize,
55
+ teacher_idx: Option<usize>,
56
+ duration: u32,
57
+ required_room_kind: RoomKind,
58
+ ) -> Self {
59
+ Self::with_details(
60
+ index,
61
+ subject,
62
+ group_idx,
63
+ 30,
64
+ teacher_idx,
65
+ duration,
66
+ required_room_kind,
67
+ )
68
+ }
69
+
70
+ /// Builds an unassigned lesson with every field needed by the data seed.
71
+ pub fn with_details(
72
+ index: usize,
73
+ subject: String,
74
+ group_idx: usize,
75
+ student_count: usize,
76
+ teacher_idx: Option<usize>,
77
+ duration: u32,
78
+ required_room_kind: RoomKind,
79
+ ) -> Self {
80
+ Self {
81
+ id: format!("lesson-{index}"),
82
+ index,
83
+ subject,
84
+ group_idx,
85
+ student_count,
86
+ teacher_idx,
87
+ duration,
88
+ required_room_kind,
89
+ // @solverforge:begin entity-variable-init
90
+ timeslot_idx: None,
91
+ room_idx: None,
92
+ // @solverforge:end entity-variable-init
93
+ }
94
+ }
95
+ }
96
+
97
+ #[cfg(test)]
98
+ mod tests {
99
+ use super::*;
100
+
101
+ #[test]
102
+ fn test_lesson_construction() {
103
+ let entity = Lesson::new(
104
+ 0,
105
+ "test".to_string(),
106
+ Default::default(),
107
+ None,
108
+ Default::default(),
109
+ );
110
+ assert_eq!(entity.id, "lesson-0");
111
+ let _ = &entity.subject;
112
+ let _ = &entity.group_idx;
113
+ let _ = &entity.student_count;
114
+ let _ = &entity.teacher_idx;
115
+ let _ = &entity.duration;
116
+ let _ = &entity.required_room_kind;
117
+ }
118
+ }
src/domain/mod.rs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ mod weekday;
11
+ pub use weekday::Weekday;
12
+
13
+ // @solverforge:begin domain-exports
14
+ mod timeslot;
15
+ mod teacher;
16
+ mod group;
17
+ mod lesson;
18
+ mod room;
19
+ mod plan;
20
+
21
+ pub use timeslot::Timeslot;
22
+ pub use teacher::Teacher;
23
+ pub use group::Group;
24
+ pub use lesson::Lesson;
25
+ pub use room::{Room, RoomKind};
26
+ pub use plan::Plan;
27
+ // @solverforge:end domain-exports
28
+ }
src/domain/plan.rs ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Planning solution for the lesson timetabling problem.
2
+ //!
3
+ //! `Plan` is both the input to SolverForge and the domain value converted to
4
+ //! JSON snapshots after solving. Facts stay read-only; lessons carry the
5
+ //! mutable timeslot and room choices.
6
+
7
+ use serde::{Deserialize, Serialize};
8
+ use solverforge::prelude::*;
9
+
10
+ // @solverforge:begin solution-imports
11
+ use super::Group;
12
+ use super::Lesson;
13
+ use super::Room;
14
+ use super::Teacher;
15
+ use super::Timeslot;
16
+ // @solverforge:end solution-imports
17
+
18
+ /// Full planning solution passed to the SolverForge runtime and HTTP API.
19
+ #[planning_solution(
20
+ constraints = "crate::constraints::create_constraints",
21
+ solver_toml = "../../solver.toml"
22
+ )]
23
+ #[derive(Serialize, Deserialize)]
24
+ pub struct Plan {
25
+ // @solverforge:begin solution-collections
26
+ /// Weekly slots a lesson can occupy.
27
+ #[problem_fact_collection]
28
+ pub timeslots: Vec<Timeslot>,
29
+ /// Teachers and their availability calendars.
30
+ #[problem_fact_collection]
31
+ pub teachers: Vec<Teacher>,
32
+ /// Student cohorts that need complete timetables.
33
+ #[problem_fact_collection]
34
+ pub groups: Vec<Group>,
35
+ /// Lesson entities whose timeslot and room variables are changed by search.
36
+ #[planning_entity_collection]
37
+ pub lessons: Vec<Lesson>,
38
+ /// Candidate teaching spaces.
39
+ #[problem_fact_collection]
40
+ pub rooms: Vec<Room>,
41
+ // @solverforge:end solution-collections
42
+ #[planning_score]
43
+ pub score: Option<HardMediumSoftScore>,
44
+ }
45
+
46
+ impl Plan {
47
+ /// Builds a normalized timetable plan from facts and lesson entities.
48
+ #[rustfmt::skip]
49
+ pub fn new(
50
+ // @solverforge:begin solution-constructor-params
51
+ timeslots: Vec<Timeslot>,
52
+ teachers: Vec<Teacher>,
53
+ groups: Vec<Group>,
54
+ lessons: Vec<Lesson>,
55
+ rooms: Vec<Room>,
56
+ // @solverforge:end solution-constructor-params
57
+ ) -> Self {
58
+ let mut schedule: Plan = Self{
59
+ // @solverforge:begin solution-constructor-init
60
+ timeslots,
61
+ teachers,
62
+ groups,
63
+ lessons,
64
+ rooms,
65
+ // @solverforge:end solution-constructor-init
66
+ score: None,
67
+ };
68
+ schedule.rebuild_derived_fields();
69
+ schedule
70
+ }
71
+
72
+ /// Recomputes indexes for entity join keys.
73
+ ///
74
+ /// This runs after generation and after transport decoding so the domain
75
+ /// model always reaches the solver in a normalized state.
76
+ ///
77
+ /// Sets the `index` field on facts and lessons to match their position in
78
+ /// their respective collections. These indexes are used as solver-facing
79
+ /// join keys for constraint streams (e.g., `lesson.timeslot_idx` joins with
80
+ /// `timeslot.index`, while `lesson.index` separates lesson pairs).
81
+ pub fn rebuild_derived_fields(&mut self) {
82
+ for (index, timeslot) in self.timeslots.iter_mut().enumerate() {
83
+ timeslot.index = index;
84
+ }
85
+ for (index, teacher) in self.teachers.iter_mut().enumerate() {
86
+ teacher.index = index;
87
+ }
88
+ for (index, group) in self.groups.iter_mut().enumerate() {
89
+ group.index = index;
90
+ }
91
+ for (index, room) in self.rooms.iter_mut().enumerate() {
92
+ room.index = index;
93
+ }
94
+
95
+ // Planning variables are optional indexes. When a stale browser payload
96
+ // sends an out-of-range index, clear it so SolverForge sees an
97
+ // unassigned variable instead of indexing past the candidate list.
98
+ for (index, lesson) in self.lessons.iter_mut().enumerate() {
99
+ lesson.index = index;
100
+ lesson.timeslot_idx = lesson
101
+ .timeslot_idx
102
+ .filter(|idx| *idx < self.timeslots.len());
103
+ lesson.room_idx = lesson.room_idx.filter(|idx| *idx < self.rooms.len());
104
+ }
105
+ }
106
+
107
+ /// Safe index lookup used by constraints and diagnostics.
108
+ #[inline]
109
+ pub fn get_timeslot(&self, idx: usize) -> Option<&Timeslot> {
110
+ self.timeslots.get(idx)
111
+ }
112
+
113
+ /// Safe index lookup used by constraints and diagnostics.
114
+ #[inline]
115
+ pub fn get_teacher(&self, idx: usize) -> Option<&Teacher> {
116
+ self.teachers.get(idx)
117
+ }
118
+
119
+ /// Safe index lookup used by constraints and diagnostics.
120
+ #[inline]
121
+ pub fn get_group(&self, idx: usize) -> Option<&Group> {
122
+ self.groups.get(idx)
123
+ }
124
+
125
+ /// Safe index lookup used by constraints and diagnostics.
126
+ #[inline]
127
+ pub fn get_room(&self, idx: usize) -> Option<&Room> {
128
+ self.rooms.get(idx)
129
+ }
130
+
131
+ /// Named slice accessor used by joins and SolverForge transport code.
132
+ #[inline]
133
+ pub fn timeslots_slice(&self) -> &[Timeslot] {
134
+ self.timeslots.as_slice()
135
+ }
136
+
137
+ /// Named slice accessor used by joins and SolverForge transport code.
138
+ #[inline]
139
+ pub fn teachers_slice(&self) -> &[Teacher] {
140
+ self.teachers.as_slice()
141
+ }
142
+
143
+ /// Named slice accessor used by joins and SolverForge transport code.
144
+ #[inline]
145
+ pub fn groups_slice(&self) -> &[Group] {
146
+ self.groups.as_slice()
147
+ }
148
+
149
+ /// Named slice accessor used by joins and SolverForge transport code.
150
+ #[inline]
151
+ pub fn rooms_slice(&self) -> &[Room] {
152
+ self.rooms.as_slice()
153
+ }
154
+ }
155
+
156
+ #[cfg(test)]
157
+ mod tests {
158
+ use super::*;
159
+ use crate::domain::Weekday;
160
+
161
+ #[test]
162
+ fn test_rebuild_derived_fields_filters_out_of_bounds_indices() {
163
+ use chrono::NaiveTime;
164
+
165
+ // Create a plan with 2 timeslots and 2 rooms
166
+ let timeslots = vec![
167
+ Timeslot::new(0, Weekday::Mon, NaiveTime::from_hms_opt(8, 0, 0).unwrap(), NaiveTime::from_hms_opt(10, 0, 0).unwrap()),
168
+ Timeslot::new(1, Weekday::Mon, NaiveTime::from_hms_opt(10, 0, 0).unwrap(), NaiveTime::from_hms_opt(12, 0, 0).unwrap()),
169
+ ];
170
+ let teachers = vec![];
171
+ let groups = vec![];
172
+ let rooms = vec![
173
+ Room::new(0, "Room A"),
174
+ Room::new(1, "Room B"),
175
+ ];
176
+ let lessons = vec![
177
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
178
+ Lesson::new(1, "Physics".to_string(), 0, None, 120),
179
+ ];
180
+
181
+ let mut plan = Plan::new(timeslots, teachers, groups, lessons, rooms);
182
+
183
+ // Manually corrupt the indices to simulate deserialization from invalid data
184
+ plan.lessons[0].timeslot_idx = Some(100); // Out of bounds
185
+ plan.lessons[0].room_idx = Some(100); // Out of bounds
186
+ plan.lessons[1].timeslot_idx = Some(1); // Valid
187
+ plan.lessons[1].room_idx = Some(1); // Valid
188
+
189
+ // Rebuild should filter out the invalid indices
190
+ plan.rebuild_derived_fields();
191
+
192
+ // timeslot_idx=100 should be filtered to None (only 2 timeslots exist)
193
+ assert_eq!(plan.lessons[0].timeslot_idx, None);
194
+ // room_idx=100 should be filtered to None (only 2 rooms exist)
195
+ assert_eq!(plan.lessons[0].room_idx, None);
196
+ // Valid indices should remain
197
+ assert_eq!(plan.lessons[1].timeslot_idx, Some(1));
198
+ assert_eq!(plan.lessons[1].room_idx, Some(1));
199
+ }
200
+
201
+ #[test]
202
+ fn test_rebuild_derived_fields_restores_lesson_indexes() {
203
+ use chrono::NaiveTime;
204
+
205
+ let timeslots = vec![Timeslot::new(
206
+ 0,
207
+ Weekday::Mon,
208
+ NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
209
+ NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
210
+ )];
211
+ let lessons = vec![
212
+ Lesson::new(0, "Math".to_string(), 0, None, 120),
213
+ Lesson::new(1, "Physics".to_string(), 0, None, 120),
214
+ ];
215
+ let mut plan = Plan::new(timeslots, vec![], vec![], lessons, vec![]);
216
+
217
+ plan.lessons[0].index = 0;
218
+ plan.lessons[1].index = 0;
219
+ plan.rebuild_derived_fields();
220
+
221
+ assert_eq!(plan.lessons[0].index, 0);
222
+ assert_eq!(plan.lessons[1].index, 1);
223
+ }
224
+
225
+ #[test]
226
+ fn test_getters_return_none_for_invalid_indices() {
227
+ use chrono::NaiveTime;
228
+
229
+ let timeslots = vec![Timeslot::new(0, Weekday::Mon, NaiveTime::from_hms_opt(8, 0, 0).unwrap(), NaiveTime::from_hms_opt(10, 0, 0).unwrap())];
230
+ let teachers = vec![Teacher::new(0, "Teacher A", [true; 10])];
231
+ let groups = vec![Group::new(0, "Group A", 30, [true; 10])];
232
+ let rooms = vec![Room::new(0, "Room A")];
233
+ let lessons = vec![];
234
+
235
+ let plan = Plan::new(timeslots, teachers, groups, lessons, rooms);
236
+
237
+ // Valid indices
238
+ assert!(plan.get_timeslot(0).is_some());
239
+ assert!(plan.get_teacher(0).is_some());
240
+ assert!(plan.get_group(0).is_some());
241
+ assert!(plan.get_room(0).is_some());
242
+
243
+ // Out of bounds indices
244
+ assert!(plan.get_timeslot(100).is_none());
245
+ assert!(plan.get_teacher(100).is_none());
246
+ assert!(plan.get_group(100).is_none());
247
+ assert!(plan.get_room(100).is_none());
248
+ }
249
+ }
src/domain/room.rs ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use solverforge::prelude::*;
3
+
4
+ /// The type of teaching space a lesson can require.
5
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
6
+ #[serde(rename_all = "snake_case")]
7
+ pub enum RoomKind {
8
+ #[default]
9
+ Lecture,
10
+ Lab,
11
+ Computer,
12
+ Language,
13
+ }
14
+
15
+ /// A physical teaching space available to the timetable.
16
+ #[problem_fact]
17
+ #[derive(Serialize, Deserialize)]
18
+ pub struct Room {
19
+ #[planning_id]
20
+ pub id: String,
21
+ #[serde(skip)]
22
+ pub index: usize, // the solver-facing join key
23
+ pub name: String,
24
+ pub kind: RoomKind,
25
+ pub capacity: usize,
26
+ }
27
+
28
+ impl Room {
29
+ pub fn new(index: usize, name: impl Into<String>) -> Self {
30
+ Self::with_kind_capacity(index, name, RoomKind::Lecture, 40)
31
+ }
32
+
33
+ pub fn with_kind_capacity(
34
+ index: usize,
35
+ name: impl Into<String>,
36
+ kind: RoomKind,
37
+ capacity: usize,
38
+ ) -> Self {
39
+ Self {
40
+ id: format!("room-{index}"),
41
+ index,
42
+ name: name.into(),
43
+ kind,
44
+ capacity,
45
+ }
46
+ }
47
+ }
48
+
49
+ #[cfg(test)]
50
+ mod tests {
51
+ use super::*;
52
+
53
+ #[test]
54
+ fn test_room_construction() {
55
+ let fact = Room::with_kind_capacity(0, "test", RoomKind::Lecture, 40);
56
+ assert_eq!(fact.id, "room-0");
57
+ assert_eq!(fact.name, "test");
58
+ assert_eq!(fact.kind, RoomKind::Lecture);
59
+ assert_eq!(fact.capacity, 40);
60
+ }
61
+ }
src/domain/teacher.rs ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use solverforge::prelude::*;
3
+
4
+ /// A teacher with a weekly availability calendar.
5
+ #[problem_fact]
6
+ #[derive(Serialize, Deserialize)]
7
+ pub struct Teacher {
8
+ #[planning_id]
9
+ pub id: String,
10
+ #[serde(skip)]
11
+ pub index: usize, // the solver-facing join key
12
+ pub name: String,
13
+ pub availability: Vec<bool>,
14
+ }
15
+
16
+ impl Teacher {
17
+ pub fn new(index: usize, name: impl Into<String>, availability: impl Into<Vec<bool>>) -> Self {
18
+ Self {
19
+ id: format!("teacher-{index}"),
20
+ index,
21
+ name: name.into(),
22
+ availability: availability.into(),
23
+ }
24
+ }
25
+ }
26
+
27
+ #[cfg(test)]
28
+ mod tests {
29
+ use super::*;
30
+
31
+ #[test]
32
+ fn test_teacher_construction() {
33
+ let fact = Teacher::new(0, "test", vec![true; 40]);
34
+ assert_eq!(fact.index, 0);
35
+ assert_eq!(fact.id, "teacher-0");
36
+ assert_eq!(fact.name, "test");
37
+ let _ = &fact.name;
38
+ let _ = &fact.availability;
39
+ }
40
+ }
src/domain/timeslot.rs ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::Weekday;
2
+ use chrono::NaiveTime;
3
+ use serde::{Deserialize, Serialize};
4
+ use solverforge::prelude::*;
5
+
6
+ /// A candidate teaching period that the solver can assign to a lesson.
7
+ #[problem_fact]
8
+ #[derive(Default, Serialize, Deserialize)]
9
+ pub struct Timeslot {
10
+ #[planning_id]
11
+ pub id: String,
12
+ #[serde(skip)]
13
+ pub index: usize, // the solver-facing join key
14
+ pub day_of_week: Weekday,
15
+ pub start_time: NaiveTime,
16
+ pub end_time: NaiveTime,
17
+ }
18
+
19
+ impl Timeslot {
20
+ pub fn new(
21
+ index: usize,
22
+ day_of_week: Weekday,
23
+ start_time: NaiveTime,
24
+ end_time: NaiveTime,
25
+ ) -> Self {
26
+ Self {
27
+ id: format!("timeslot-{index}"),
28
+ index,
29
+ day_of_week,
30
+ start_time,
31
+ end_time,
32
+ }
33
+ }
34
+ }
35
+
36
+ #[cfg(test)]
37
+ mod tests {
38
+ use super::*;
39
+
40
+ #[test]
41
+ fn test_timeslot_construction() {
42
+ let fact = Timeslot::new(0, Weekday::Mon, Default::default(), Default::default());
43
+ assert_eq!(fact.index, 0);
44
+ assert_eq!(fact.id, "timeslot-0");
45
+
46
+ let _ = &fact.day_of_week;
47
+ let _ = &fact.start_time;
48
+ let _ = &fact.end_time;
49
+ }
50
+ }
src/domain/weekday.rs ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+
3
+ /// School-day enum used by generated data and SolverForge planning facts.
4
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
5
+ pub enum Weekday {
6
+ #[default]
7
+ Mon,
8
+ Tue,
9
+ Wed,
10
+ Thu,
11
+ Fri,
12
+ }
src/lib.rs ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! SolverForge lesson-timetabling 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 timetable 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,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Axum entrypoint for the lesson-timetabling app.
2
+ //!
3
+ //! Run with `make run-release`, then open the printed local URL. The same
4
+ //! binary is used by the Docker Space image, where `PORT` is provided by the
5
+ //! platform.
6
+
7
+ use solverforge_lessons::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-lessons 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).await.unwrap();
44
+ }
src/solver/event_payload.rs ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! JSON event payloads sent over the lessons 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
+ //! timetable renderer.
6
+
7
+ use serde::Serialize;
8
+ use std::time::Duration;
9
+
10
+ use solverforge::{
11
+ HardMediumSoftScore, SolverEventMetadata, SolverLifecycleState, SolverSnapshot, SolverStatus,
12
+ SolverTelemetry, SolverTerminalReason,
13
+ };
14
+
15
+ use crate::api::PlanDto;
16
+ use crate::domain::Plan;
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<HardMediumSoftScore>,
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<HardMediumSoftScore>,
75
+ snapshot: &SolverSnapshot<Plan>,
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<HardMediumSoftScore>,
103
+ solution: Option<&Plan>,
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
+ }