Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ·
4b94493
0
Parent(s):
chore: sync uc-lessons Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- AGENTS.md +79 -0
- CHANGELOG.md +19 -0
- Cargo.lock +1630 -0
- Cargo.toml +29 -0
- Dockerfile +38 -0
- Makefile +285 -0
- README.md +217 -0
- WIREFRAME.md +51 -0
- docs/screenshot.png +3 -0
- solver.toml +17 -0
- solverforge.app.toml +119 -0
- src/api/dto.rs +295 -0
- src/api/mod.rs +11 -0
- src/api/routes.rs +220 -0
- src/api/sse.rs +74 -0
- src/constraints/assign_room.rs +52 -0
- src/constraints/assign_timeslot.rs +52 -0
- src/constraints/group_availability.rs +77 -0
- src/constraints/late_lesson.rs +64 -0
- src/constraints/mod.rs +48 -0
- src/constraints/no_group_conflict.rs +173 -0
- src/constraints/no_room_conflict.rs +186 -0
- src/constraints/no_teacher_conflict.rs +193 -0
- src/constraints/repeated_subject_day.rs +86 -0
- src/constraints/room_capacity.rs +74 -0
- src/constraints/room_kind.rs +73 -0
- src/constraints/teacher_availability.rs +77 -0
- src/data/data_seed.rs +19 -0
- src/data/data_seed/entrypoints.rs +121 -0
- src/data/data_seed/groups.rs +58 -0
- src/data/data_seed/large.rs +43 -0
- src/data/data_seed/lessons.rs +172 -0
- src/data/data_seed/rooms.rs +26 -0
- src/data/data_seed/solve_tests.rs +152 -0
- src/data/data_seed/teachers.rs +67 -0
- src/data/data_seed/timeslots.rs +208 -0
- src/data/data_seed/vocabulary.rs +280 -0
- src/data/mod.rs +8 -0
- src/domain/group.rs +48 -0
- src/domain/lesson.rs +118 -0
- src/domain/mod.rs +28 -0
- src/domain/plan.rs +249 -0
- src/domain/room.rs +61 -0
- src/domain/teacher.rs +40 -0
- src/domain/timeslot.rs +50 -0
- src/domain/weekday.rs +12 -0
- src/lib.rs +12 -0
- src/main.rs +44 -0
- 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 |
+

|
| 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
|
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 ×lots {
|
| 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 ×lots {
|
| 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 |
+
}
|