diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..46432e4563e47e050e1a3b98453ec9d28ee1eaf5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docs/screenshot.png filter=lfs diff=lfs merge=lfs -text diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..864100edc60a6613df0277c5f7d6d9f24f5a754c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,79 @@ +# Repository Guidelines + +## Project Structure And Naming + +`solverforge-lessons` is a Rust 1.95 SolverForge lesson-timetabling app with an +Axum server and static browser workspace. Keep the repo-local directory name +`uc-lessons`; product copy, metadata, and UI labels should use +`solverforge-lessons` or SolverForge Lessons. + +- `src/domain/mod.rs` owns the `solverforge::planning_model!` manifest. +- `src/domain/plan.rs` owns the `Plan` planning solution. +- `src/domain/lesson.rs` owns the `Lesson` planning entity and its + `timeslot_idx` and `room_idx` scalar variables. +- `src/domain/{timeslot,teacher,group,room}.rs` own the problem facts. +- `src/constraints/` owns one timetable scoring rule per file plus `mod.rs` + assembly. +- `src/data/data_seed/` owns deterministic `LARGE` demo generation. +- `src/api/` owns REST, DTO, and SSE surfaces. +- `src/solver/` owns retained-job runtime orchestration. +- `static/` owns the browser shell and generated view model. +- `Dockerfile`, `Makefile`, `solver.toml`, and `solverforge.app.toml` define + the deployment and runtime contract. + +Keep the canonical solution name `Plan` and the public demo id `LARGE`. + +## Build And Validation Commands + +- `make help` shows the supported command surface. +- `make run-release` runs the app locally on `:7860`. +- `make test` runs Rust tests, frontend syntax checks, and the Playwright smoke. +- `make test-e2e` runs the real browser smoke. +- `make ci-local` runs formatting, clippy, release build, standard tests, and + the Space Docker image build. +- `make test-slow` runs the ignored large-demo acceptance solve. +- `make pre-release` runs `ci-local` plus the slow acceptance solve. +- `cargo test` runs Rust unit tests. +- `PORT=7861 cargo run --bin solverforge-lessons` runs the app on an alternate + port when `7860` is already occupied. + +Use the Makefile as the authoritative local workflow. + +## Documentation And Commenting Policy + +Assume a reader who is new to SolverForge and new to optimization modeling. + +- Keep `README.md`, `WIREFRAME.md`, this file, `solver.toml`, + `solverforge.app.toml`, `static/sf-config.json`, and `docs/screenshot.png` + aligned. +- Add module-level docs or comments for modules that explain their role in the + app and where they sit in the data flow. +- Add function comments when the function coordinates SolverForge concepts, + rebuilds invariants, shapes demo data, converts between layers, or otherwise + does something a beginner would not infer from the signature. +- Write comments that explain intent, domain meaning, invariants, and runtime + consequences. Do not write comments that merely restate syntax. +- Keep comments present-tense and source-backed. If behavior changes, update or + delete the stale comment in the same patch. +- When docs mention versions, counts, routes, demo IDs, solver policy, or + validation expectations, verify those facts against current code in the same + patch. + +The standard to aim for is: a new reader should understand why a piece of code +exists before they need to understand every line of how it works. + +## Testing Guidance + +Add Rust tests next to the behavior they protect. Real browser flows belong in +`tests/e2e/`. If you change solver behavior, run `cargo test` and the ignored +large-demo solve. If you change UI structure, run the frontend syntax check and +Playwright smoke. + +## Runtime Notes + +`solver.toml` is embedded by `Plan` through the planning-solution macro. Treat +it as the solver policy source of truth. + +The app serves stock `solverforge-ui` assets, local static app modules, and +Axum API routes from one process. Retained solver jobs are controlled through +REST and observed through SSE. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..3149f2f0b39f3d6d9c7c7cce64ca8566ddc83e3a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this use case are documented in this file. + +## 2.0.0 (2026-05-14) + +### Maintenance + +* **release:** set the public app release line to 2.0.0 across Cargo metadata and release validation. + +## 0.1.0 (2026-05-14) + +### Features + +* **lessons:** publish the SolverForge lessons scheduling use case in the bundle. + +### Maintenance + +* **release:** align the bundled app with SolverForge 0.13.1 and solverforge-ui 0.6.5. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..9423afbac9138379cbbe39ca8fde9c856aaec9a8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1630 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "solverforge" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86fef307d129ed9d674f29e2f37be912a9b901a4fe9f43348f1044879ef7094" +dependencies = [ + "solverforge-config", + "solverforge-console", + "solverforge-core", + "solverforge-cvrp", + "solverforge-macros", + "solverforge-scoring", + "solverforge-solver", + "tokio", +] + +[[package]] +name = "solverforge-config" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434a6c8f9f308d20c7d364324de9d0dbb27d5fe03a7e8f1cead0c2574ebe947e" +dependencies = [ + "serde", + "serde_yaml", + "solverforge-core", + "thiserror", + "toml", +] + +[[package]] +name = "solverforge-console" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456459eb2efbb3d57a6b5234c7d29a2aab84c8d14d5a09807ab0ac94bf9a786" +dependencies = [ + "num-format", + "owo-colors", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "solverforge-core" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc29944f60ac425f53b4605ea2f0fa5fa499549139df7a889aa5fdc47bcc066" +dependencies = [ + "serde", + "thiserror", +] + +[[package]] +name = "solverforge-cvrp" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cbe85229dd46e845f95f1f4d2ac993145680b02e044acdce220ee4ddecbd36" +dependencies = [ + "solverforge-solver", +] + +[[package]] +name = "solverforge-lessons" +version = "2.0.0" +dependencies = [ + "axum", + "chrono", + "parking_lot", + "serde", + "serde_json", + "solverforge", + "solverforge-ui", + "tokio", + "tokio-stream", + "tower", + "tower-http", + "uuid", +] + +[[package]] +name = "solverforge-macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f830c19a7d9c7fe10911cf597b0b98c6d63ef2ec70330ac6e0b2746e8db8d98" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "solverforge-scoring" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efaede461a62baa20a0f65887bda5760158a125a831ecc73c50d713ac9d6ce7" +dependencies = [ + "solverforge-core", + "thiserror", +] + +[[package]] +name = "solverforge-solver" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0156969a9e5d965d7fda36a8581646115e5594a2d50d1036e101dcb001167530" +dependencies = [ + "rand", + "rand_chacha", + "rayon", + "serde", + "smallvec", + "solverforge-config", + "solverforge-core", + "solverforge-scoring", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "solverforge-ui" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7fa2d78c84af9a1e264adcffc1bdf8cb4edab8d73a3543fb448d166c95596f" +dependencies = [ + "axum", + "include_dir", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..b910ae48ede1383009177074af3d18bb6003171f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "solverforge-lessons" +version = "2.0.0" +edition = "2021" +rust-version = "1.95" +description = "Constraint optimizer built with SolverForge" + +[[bin]] +name = "solverforge-lessons" +path = "src/main.rs" + +[dependencies] +solverforge = { version = "0.13.1", features = ["serde", "console", "verbose-logging"] } +solverforge-ui = { version = "0.6.5" } +# Web server +axum = "0.8.9" +tokio = { version = "1.52.1", features = ["full"] } +tokio-stream = { version = "0.1.18", features = ["sync"] } +tower-http = { version = "0.6.8", features = ["fs", "cors"] } +tower = "0.5.3" + +# Serialization +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" + +# Utilities +uuid = { version = "1.23.1", features = ["v4", "serde"] } +parking_lot = "0.12.5" +chrono = { version = "0.4.44", features = ["serde"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..07f1b4d52770d093540aae9cd6484d771ac73b99 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Multi-stage build for solverforge-lessons. +FROM rust:1.95-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache musl-dev + +WORKDIR /build + +COPY Cargo.toml Cargo.lock ./ +COPY src/ ./src/ +COPY static/ ./static/ +COPY solver.toml ./solver.toml + +# Build release binary with musl target for static linking +RUN cargo build --release --target x86_64-unknown-linux-musl + +FROM alpine:latest + +RUN apk add --no-cache ca-certificates + +WORKDIR /app + +# Copy binary from builder (musl static binary) +COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/solverforge-lessons ./solverforge-lessons + +# Copy static files +COPY --from=builder /build/static/ ./static/ + +# Copy solver config +COPY --from=builder /build/solver.toml ./solver.toml + +ENV PORT=7860 + +# Expose the same port the container binds to by default. +EXPOSE 7860 + +# Run the application +CMD ["./solverforge-lessons"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..fb6141aebff9e5d816060c4b7054b6ea69a89d18 --- /dev/null +++ b/Makefile @@ -0,0 +1,285 @@ +# SolverForge Lessons Makefile +# Rust + frontend + Space-oriented local build system. + +SHELL := /bin/sh +.SHELLFLAGS := -eu -c +unexport BASH_FUNC_mc%% + +# ============== Colors & Symbols ============== +GREEN := \033[92m +EMERALD := \033[38;2;16;185;129m +CYAN := \033[96m +YELLOW := \033[93m +RED := \033[91m +GRAY := \033[90m +BOLD := \033[1m +RESET := \033[0m + +CHECK := OK +CROSS := FAIL +ARROW := => +PROGRESS := .. + +# ============== Project Metadata ============== +APP_NAME := solverforge-lessons +PACKAGE_NAME := solverforge-lessons +VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1) +RELEASE_TAG := $(PACKAGE_NAME)@$(VERSION) +RUST_VERSION := 1.95+ +PORT ?= 7860 +E2E_PORT ?= 7960 +DOCKER_IMAGE ?= $(PACKAGE_NAME) +DOCKER_CONTEXT ?= . +DOCKERFILE_PATH := Dockerfile + +# ============== Phony Targets ============== +.PHONY: banner help doctor build build-release run run-release test test-rust \ + test-frontend-syntax test-frontend test-e2e test-slow test-one lint fmt \ + fmt-check clippy check ci-local space-ci space-build space-run docker-build \ + docker-run pre-release release-ci release-info version clean watch require-node require-docker + +.DEFAULT_GOAL := help + +# ============== Banner ============== +banner: + @printf "$(EMERALD)$(BOLD) ____ _ _____\n" + @printf " / ___| ___ | |_ _____ _ __| ___|__ _ __ __ _ ___\n" + @printf " \\___ \\\\ / _ \\\\| \\\\ \\\\ / / _ \\\\ '__| |_ / _ \\\\| '__/ _\` |/ _ \\\\\n" + @printf " ___) | (_) | |\\\\ V / __/ | | _| (_) | | | (_| | __/\n" + @printf " |____/ \\\\___/|_| \\_/ \\___|_| |_| \\___/|_| \\__, |\\___|\n" + @printf " |___/$(RESET)\n" + @printf " $(GRAY)v$(VERSION)$(RESET) $(EMERALD)Lessons demo build system$(RESET)\n\n" + +# ============== Environment Checks ============== +require-node: + @command -v node >/dev/null 2>&1 || (printf "$(RED)$(CROSS) node is required for frontend validation$(RESET)\n" && exit 1) + +require-docker: + @command -v docker >/dev/null 2>&1 || (printf "$(RED)$(CROSS) docker is required for Space/Docker targets$(RESET)\n" && exit 1) + +doctor: banner + @printf "$(CYAN)$(BOLD)Environment Check$(RESET)\n\n" + @missing=0; \ + if command -v cargo >/dev/null 2>&1; then \ + printf "$(GREEN)$(CHECK) cargo: $$(cargo --version)$(RESET)\n"; \ + else \ + printf "$(RED)$(CROSS) cargo not found$(RESET)\n"; missing=1; \ + fi; \ + if command -v rustc >/dev/null 2>&1; then \ + printf "$(GREEN)$(CHECK) rustc: $$(rustc --version)$(RESET)\n"; \ + else \ + printf "$(RED)$(CROSS) rustc not found$(RESET)\n"; missing=1; \ + fi; \ + if command -v node >/dev/null 2>&1; then \ + printf "$(GREEN)$(CHECK) node: $$(node --version)$(RESET)\n"; \ + else \ + printf "$(RED)$(CROSS) node not found$(RESET)\n"; missing=1; \ + fi; \ + if command -v docker >/dev/null 2>&1; then \ + printf "$(GREEN)$(CHECK) docker: $$(docker --version)$(RESET)\n"; \ + else \ + printf "$(YELLOW)! docker not found; Space/Docker targets will be unavailable$(RESET)\n"; \ + fi; \ + printf "$(GRAY)Docker build context: $(DOCKER_CONTEXT)$(RESET)\n"; \ + printf "$(GRAY)Default app port: $(PORT)$(RESET)\n"; \ + printf "$(GRAY)Browser smoke port: $(E2E_PORT)$(RESET)\n"; \ + if [ $$missing -ne 0 ]; then exit 1; fi + @printf "\n" + +# ============== Build & Run ============== +build: banner + @printf "$(ARROW) $(BOLD)Building $(PACKAGE_NAME)...$(RESET)\n" + @cargo build --bin $(APP_NAME) && \ + printf "$(GREEN)$(CHECK) Debug build successful$(RESET)\n\n" || \ + (printf "$(RED)$(CROSS) Debug build failed$(RESET)\n\n" && exit 1) + +build-release: banner + @printf "$(ARROW) $(BOLD)Building release binary...$(RESET)\n" + @cargo build --release --bin $(APP_NAME) && \ + printf "$(GREEN)$(CHECK) Release build successful$(RESET)\n\n" || \ + (printf "$(RED)$(CROSS) Release build failed$(RESET)\n\n" && exit 1) + +run: + @printf "$(ARROW) Running $(PACKAGE_NAME) on port $(PORT)...\n" + @PORT=$(PORT) cargo run --bin $(APP_NAME) + +run-release: + @printf "$(ARROW) Running release build on port $(PORT)...\n" + @PORT=$(PORT) cargo run --release --bin $(APP_NAME) + +# ============== Test Targets ============== +test: test-rust test-frontend test-e2e + @printf "\n$(GREEN)$(BOLD)$(CHECK) Standard validation passed$(RESET)\n\n" + +test-rust: banner + @printf "$(ARROW) $(BOLD)Running cargo test --quiet...$(RESET)\n" + @cargo test --quiet && \ + printf "\n$(GREEN)$(CHECK) Rust tests passed$(RESET)\n\n" || \ + (printf "\n$(RED)$(CROSS) Rust tests failed$(RESET)\n\n" && exit 1) + +test-frontend-syntax: require-node + @printf "$(PROGRESS) Checking frontend module syntax...\n" + @find static -name '*.js' -print0 | xargs -0 -n1 node --check && \ + printf "$(GREEN)$(CHECK) Frontend syntax checks passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Frontend syntax checks failed$(RESET)\n" && exit 1) + +test-frontend: test-frontend-syntax + @printf "$(GREEN)$(CHECK) Frontend validation passed$(RESET)\n" + +test-e2e: build-release require-node + @printf "$(PROGRESS) Running browser smoke test on port $(E2E_PORT)...\n" + @log=$$(mktemp); \ + PORT=$(E2E_PORT) target/release/$(APP_NAME) >"$$log" 2>&1 & \ + pid=$$!; \ + trap 'kill $$pid >/dev/null 2>&1 || true; rm -f "$$log"' EXIT INT TERM; \ + i=0; \ + until curl -fsS "http://127.0.0.1:$(E2E_PORT)/" >/dev/null 2>&1; do \ + i=$$((i + 1)); \ + if [ $$i -ge 60 ]; then \ + printf "$(RED)$(CROSS) app did not become ready$(RESET)\n"; \ + cat "$$log"; \ + exit 1; \ + fi; \ + sleep 0.2; \ + done; \ + 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();'; \ + kill $$pid >/dev/null 2>&1 || true; \ + printf "$(GREEN)$(CHECK) Browser smoke test passed$(RESET)\n" + +test-slow: banner + @printf "$(ARROW) $(BOLD)Running large demo acceptance solve...$(RESET)\n" + @cargo test large_demo_solves_to_feasible_progressing_schedule -- --ignored --nocapture && \ + printf "\n$(GREEN)$(CHECK) Slow acceptance solve passed$(RESET)\n\n" || \ + (printf "\n$(RED)$(CROSS) Slow acceptance solve failed$(RESET)\n\n" && exit 1) + +test-one: + @test -n "$(TEST)" || (printf "$(RED)$(CROSS) TEST=name is required$(RESET)\n" && exit 1) + @printf "$(PROGRESS) Running test: $(YELLOW)$(TEST)$(RESET)\n" + @RUST_LOG=info cargo test "$(TEST)" -- --nocapture + +# ============== Lint & Format ============== +fmt: + @printf "$(PROGRESS) Formatting Rust code...\n" + @cargo fmt + @printf "$(GREEN)$(CHECK) Code formatted$(RESET)\n" + +fmt-check: + @printf "$(PROGRESS) Checking Rust formatting...\n" + @cargo fmt --check && \ + printf "$(GREEN)$(CHECK) Formatting valid$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Formatting issues found$(RESET)\n" && exit 1) + +clippy: + @printf "$(PROGRESS) Running clippy...\n" + @cargo clippy --all-targets -- -D warnings && \ + printf "$(GREEN)$(CHECK) Clippy passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Clippy warnings found$(RESET)\n" && exit 1) + +lint: fmt-check clippy test-frontend-syntax + @printf "\n$(GREEN)$(BOLD)$(CHECK) Lint checks passed$(RESET)\n\n" + +check: lint test + +# ============== Space & Docker ============== +docker-build: require-docker + @printf "$(PROGRESS) Building Docker image $(DOCKER_IMAGE)...\n" + @docker build -f "$(DOCKERFILE_PATH)" -t "$(DOCKER_IMAGE)" "$(DOCKER_CONTEXT)" && \ + printf "$(GREEN)$(CHECK) Docker image built$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Docker build failed$(RESET)\n" && exit 1) + +docker-run: require-docker + @printf "$(ARROW) Running $(DOCKER_IMAGE) on port $(PORT)...\n" + @docker run --rm -it -e PORT=$(PORT) -p $(PORT):$(PORT) "$(DOCKER_IMAGE)" + +space-build: docker-build + +space-run: space-build + @printf "$(GREEN)$(CHECK) Starting local container that mirrors the Space image$(RESET)\n" + @$(MAKE) docker-run --no-print-directory PORT=$(PORT) DOCKER_IMAGE=$(DOCKER_IMAGE) + +space-ci: ci-local + +# ============== CI & Release Validation ============== +ci-local: banner + @printf "$(CYAN)$(BOLD)Local Space Validation Pipeline$(RESET)\n\n" + @printf "$(PROGRESS) Step 1/5: Format check...\n" + @$(MAKE) fmt-check --no-print-directory + @printf "$(PROGRESS) Step 2/5: Clippy...\n" + @$(MAKE) clippy --no-print-directory + @printf "$(PROGRESS) Step 3/5: Release build...\n" + @$(MAKE) build-release --no-print-directory + @printf "$(PROGRESS) Step 4/5: Standard test surface...\n" + @$(MAKE) test --no-print-directory + @printf "$(PROGRESS) Step 5/5: Docker/Space image build...\n" + @$(MAKE) space-build --no-print-directory + @printf "\n$(GREEN)$(BOLD)$(CHECK) LOCAL SPACE VALIDATION PASSED$(RESET)\n\n" + +pre-release: banner + @printf "$(CYAN)$(BOLD)Pre-Release Validation v$(VERSION)$(RESET)\n\n" + @$(MAKE) ci-local --no-print-directory + @printf "$(PROGRESS) Final step: slow acceptance solve...\n" + @$(MAKE) test-slow --no-print-directory + @printf "$(GREEN)$(BOLD)$(CHECK) Ready for publication or Space update$(RESET)\n\n" + +release-ci: ci-local + @printf "$(GREEN)$(BOLD)$(CHECK) Release CI passed for $(RELEASE_TAG)$(RESET)\n\n" + +release-info: + @printf "$(CYAN)Package:$(RESET) $(YELLOW)$(BOLD)$(PACKAGE_NAME)$(RESET)\n" + @printf "$(CYAN)Version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n" + @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n" + +# ============== Metadata & Cleanup ============== +version: + @printf "$(CYAN)Current version:$(RESET) $(YELLOW)$(BOLD)$(VERSION)$(RESET)\n" + @printf "$(CYAN)Release tag:$(RESET) $(YELLOW)$(BOLD)$(RELEASE_TAG)$(RESET)\n" + @printf "$(CYAN)Default port:$(RESET) $(YELLOW)$(BOLD)$(PORT)$(RESET)\n" + @printf "$(CYAN)Browser smoke port:$(RESET) $(YELLOW)$(BOLD)$(E2E_PORT)$(RESET)\n" + +clean: + @printf "$(ARROW) Cleaning build artifacts...\n" + @cargo clean + @printf "$(GREEN)$(CHECK) Clean complete$(RESET)\n" + +watch: + @printf "$(ARROW) Watching and rerunning the app on port $(PORT)...\n" + @cargo watch --version >/dev/null 2>&1 || \ + (printf "$(RED)$(CROSS) cargo-watch is required for make watch$(RESET)\n" && exit 1) + @PORT=$(PORT) cargo watch -x "run --bin $(APP_NAME)" + +# ============== Help ============== +help: banner + @printf "$(CYAN)$(BOLD)Environment:$(RESET)\n" + @printf " $(GREEN)make doctor$(RESET) - Check local cargo/rustc/node readiness\n" + @printf "\n$(CYAN)$(BOLD)Build & Run:$(RESET)\n" + @printf " $(GREEN)make build$(RESET) - Build the app in debug mode\n" + @printf " $(GREEN)make build-release$(RESET) - Build the app in release mode\n" + @printf " $(GREEN)make run$(RESET) - Run locally on port $(PORT)\n" + @printf " $(GREEN)make run-release$(RESET) - Run the release build on port $(PORT)\n" + @printf "\n$(CYAN)$(BOLD)Tests & Validation:$(RESET)\n" + @printf " $(GREEN)make test$(RESET) - Run Rust, frontend syntax, and browser smoke checks\n" + @printf " $(GREEN)make test-rust$(RESET) - Run Rust tests only\n" + @printf " $(GREEN)make test-frontend$(RESET) - Run frontend syntax checks\n" + @printf " $(GREEN)make test-e2e$(RESET) - Run a Playwright browser smoke check\n" + @printf " $(GREEN)make test-slow$(RESET) - Run the ignored large-demo acceptance solve\n" + @printf " $(GREEN)make test-one TEST=name$(RESET) - Run a specific Rust test with output\n" + @printf " $(GREEN)make lint$(RESET) - Run fmt-check, clippy, and frontend syntax checks\n" + @printf " $(GREEN)make check$(RESET) - Run lint plus standard tests\n" + @printf " $(GREEN)make ci-local$(RESET) - Run local Space validation pipeline\n" + @printf " $(GREEN)make release-ci$(RESET) - Run the tag-publish CI gate for this app\n" + @printf " $(GREEN)make pre-release$(RESET) - Run ci-local plus slow acceptance solve\n" + @printf "\n$(CYAN)$(BOLD)Space & Docker:$(RESET)\n" + @printf " $(GREEN)make space-build$(RESET) - Build the Docker image used for Space deployment\n" + @printf " $(GREEN)make space-run$(RESET) - Build and run that image locally on port $(PORT)\n" + @printf " $(GREEN)make docker-build$(RESET) - Build the Docker image directly\n" + @printf " $(GREEN)make docker-run$(RESET) - Run the Docker image directly\n" + @printf "\n$(CYAN)$(BOLD)Other:$(RESET)\n" + @printf " $(GREEN)make fmt$(RESET) - Format Rust code\n" + @printf " $(GREEN)make release-info$(RESET) - Show package version and app-scoped release tag\n" + @printf " $(GREEN)make version$(RESET) - Show version and default ports\n" + @printf " $(GREEN)make clean$(RESET) - Clean build artifacts\n" + @printf " $(GREEN)make watch$(RESET) - Watch source files and rerun the app\n" + @printf " $(GREEN)make help$(RESET) - Show this help message\n" + @printf "\n$(GRAY)Rust version required: $(RUST_VERSION)$(RESET)\n" + @printf "$(GRAY)Current version: v$(VERSION)$(RESET)\n" + @printf "$(GRAY)Release tag: $(RELEASE_TAG)$(RESET)\n" diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ddfda5fd13e2639d5dec8d75765da6c7679bcc90 --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +--- +title: SolverForge Lessons +emoji: 📚 +colorFrom: blue +colorTo: green +sdk: docker +app_port: 7860 +pinned: false +license: apache-2.0 +short_description: SolverForge lesson scheduling example +--- + +# SolverForge Lessons + +![SolverForge Lessons screenshot](docs/screenshot.png) + +`solverforge-lessons` is a SolverForge lesson-timetabling app with retained +jobs, cohort timetable views, and a browser schedule workspace. + +It answers one concrete question: + +"Given lessons, teachers, student groups, rooms, and weekly timeslots, which +timeslot and room should each lesson receive?" + +## Acknowledgement + +Thanks to `@benabel` (Prof. Benjamin Abel), whose referral is also credited in +the core SolverForge README. + +## Quick Start + +```sh +make run-release +``` + +Then open `http://localhost:7860`. + +To inspect the supported command surface: + +```sh +make help +``` + +## Documentation Map + +- `README.md` + Quick start, model concepts, validation, REST API, and solver policy. +- `WIREFRAME.md` + As-built architecture and runtime/data flow across backend, runtime, and UI. +- `AGENTS.md` + Codex-facing maintenance, validation, and documentation rules. +- `Makefile` + Supported local commands for development, validation, Docker, and Space work. +- `Dockerfile` + Docker Space image build using Rust 1.95 and the declared crates.io line. + +## Current Dependency Shape + +- Package: `solverforge-lessons`; version is declared in `Cargo.toml` +- Release binary: `solverforge-lessons` +- Rust: `1.95` +- SolverForge runtime: `solverforge` `0.13.1` +- Browser UI assets: `solverforge-ui` `0.6.5` +- Scaffold metadata: `solverforge-cli` `2.0.4` in `solverforge.app.toml` + +The app serves registry-backed Rust dependencies, local static browser modules, +and Axum API routes from one process. + +## Model Concepts + +- `Timeslot`, `Teacher`, `Group`, and `Room` are problem facts: input data the + solver reads but does not move. +- `Lesson` is the planning entity: each subject meeting needs a timeslot and a + room. +- `Lesson.timeslot_idx` is a scalar planning variable: the timeslot index + SolverForge changes. +- `Lesson.room_idx` is a scalar planning variable: the room index SolverForge + changes. +- `Plan` is the planning solution with the current `HardMediumSoftScore`. + +The app ships one deterministic `LARGE` dataset with 300 lessons, 12 student +groups, 40 weekly timeslots, and 10 typed rooms. It starts with every lesson +unassigned, so the initial score is `0hard/-600medium/0soft`. + +## Constraints + +Hard constraints: + +- Teachers can teach only in available timeslots. +- Student groups can attend only in available timeslots. +- A room must be large enough for the assigned group. +- A teacher cannot teach overlapping lessons. +- A group cannot attend overlapping lessons. +- A room cannot host overlapping lessons. + +Medium constraints: + +- Every lesson should receive a timeslot. +- Every lesson should receive a room. + +Soft constraints: + +- Room kind should match the subject. +- Lessons should avoid late-day slots when possible. +- The same subject should not repeat twice in one day for a cohort. + +## REST API + +- `GET /health` +- `GET /info` +- `GET /demo-data` +- `GET /demo-data/{id}` +- `POST /jobs` +- `GET /jobs/{id}` +- `DELETE /jobs/{id}` +- `GET /jobs/{id}/status` +- `GET /jobs/{id}/snapshot` +- `GET /jobs/{id}/analysis` +- `POST /jobs/{id}/pause` +- `POST /jobs/{id}/resume` +- `POST /jobs/{id}/cancel` +- `GET /jobs/{id}/events` + +`snapshot_revision={n}` is optional for snapshots and analysis. SSE clients +receive a bootstrap event and then live retained-job events. + +## Solver Policy + +`solver.toml` is embedded by `Plan` and is the runtime source of truth. + +- `cheapest_insertion` assigns timeslots and rooms during construction. +- `construction_obligation = "assign_when_candidate_exists"` makes + construction fill variables when a legal candidate exists. +- `value_candidate_limit = 40` bounds construction candidate scanning. +- Local search uses `late_acceptance` with an accepted-count forager. +- Solving stops after 30 seconds. + +The slow acceptance test expects solving to reach hard and medium feasibility +from the fully unassigned public instance while soft penalties continue to +represent timetable quality. + +## Validation + +Standard validation: + +```sh +make test +``` + +Full local validation: + +```sh +make ci-local +``` + +Slow acceptance solve: + +```sh +make test-slow +``` + +`make test` runs Rust tests, frontend syntax checks, and a Playwright browser +smoke. `make ci-local` adds formatting, clippy, release build, and Docker image +build. `make pre-release` runs `ci-local` plus the slow acceptance solve. + +## Hugging Face Space Deployment + +This repo is Docker-Space ready. The Space reads the README front matter, +builds `Dockerfile`, and expects the app to bind `PORT=7860`. + +Local Space-equivalent commands: + +```sh +make space-build +make space-run +``` + +## Read The Code In This Order + +1. `src/domain/mod.rs` + The `planning_model!` manifest and public domain exports. +2. `src/domain/plan.rs` + The `Plan` solution, fact collections, lesson entities, and normalized + index fields. +3. `src/domain/lesson.rs` + The planning entity and the two scalar planning variables. +4. `src/domain/{timeslot,teacher,group,room}.rs` + The problem facts the constraints join against. +5. `src/constraints/mod.rs` and `src/constraints/*.rs` + The score model, one timetable rule per file. +6. `src/data/data_seed/entrypoints.rs` + Public demo-data IDs and generator dispatch. +7. `src/data/data_seed/large.rs` + The published instance builder. +8. `src/solver/service.rs` + Retained-job orchestration over `SolverManager`. +9. `src/api/routes.rs`, `src/api/dto.rs`, and `src/api/sse.rs` + HTTP routes, transport DTOs, and live-event streaming. +10. `static/app.js` and `static/views.js` + Browser boot sequence, solver controls, and timetable rendering. + +## Project Shape + +- `src/domain/` + Planning model, domain types, planning variables, and derived indexes. +- `src/constraints/` + Incremental SolverForge scoring rules. +- `src/data/` + Deterministic lesson timetable demo-data generator. +- `src/solver/` + Retained-job facade and runtime event payload formatting. +- `src/api/` + Axum routes, DTOs, and SSE endpoint. +- `static/` + Browser workspace built on stock `solverforge-ui` assets. +- `tests/e2e/` + Playwright browser smoke for the served app. diff --git a/WIREFRAME.md b/WIREFRAME.md new file mode 100644 index 0000000000000000000000000000000000000000..9b67b10323047ff424cf00135630af3e2c27549e --- /dev/null +++ b/WIREFRAME.md @@ -0,0 +1,51 @@ +# SolverForge Lessons Wireframe + +`README.md` explains how to run and use the app. This file records the current +runtime shape so maintainers can keep backend, UI, and publication metadata in +sync. + +## Runtime Flow + +```text +Browser + | + | GET / + v +Axum server in src/main.rs + | + | serves /sf/* from solverforge-ui + | serves static/* from this app + | exposes /api/* and /jobs/* + v +Retained solver service in src/solver/ + | + | builds demo data from src/data/ + | solves Plan from src/domain/ + | scores constraints from src/constraints/ + v +SSE and JSON DTOs in src/api/ +``` + +## Browser Surface + +The first viewport shows the standard SolverForge UI shell with tabs for group, +room, teacher, data, and REST API views. The Solve button starts the retained +job and the status strip reports the current score, lifecycle state, and +constraint count. + +## Model + +Lessons are planning entities. Teachers, student groups, rooms, and timeslots +are problem facts. The solver assigns each lesson to a timeslot and room while +avoiding teacher, group, and room conflicts and preferring better timetable +quality. + +## Key Files + +- `Cargo.toml`: crate and binary identity for `solverforge-lessons`. +- `solverforge.app.toml`: app metadata, entities, facts, variables, and + constraints. +- `solver.toml`: solver termination and phase policy. +- `src/main.rs`: single-process Axum server used locally and in the Space. +- `static/sf-config.json`: visible title and view metadata. +- `docs/screenshot.png`: current browser screenshot. diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..a34a8404b0ea9b83870993fc67cc1a8cbb8ad498 --- /dev/null +++ b/docs/screenshot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95bbbdb169fb6277b1be7ae04fe709ee6f4b0618675919f13453876a9b20a87f +size 281280 diff --git a/solver.toml b/solver.toml new file mode 100644 index 0000000000000000000000000000000000000000..7434a023e0d057f034b755795b3f32278c6de258 --- /dev/null +++ b/solver.toml @@ -0,0 +1,17 @@ +[[phases]] +type = "construction_heuristic" +construction_heuristic_type = "cheapest_insertion" +construction_obligation = "assign_when_candidate_exists" +value_candidate_limit = 40 + +[[phases]] +type = "local_search" +[phases.acceptor] +type = "late_acceptance" +late_acceptance_size = 400 +[phases.forager] +type = "accepted_count" +limit = 4 + +[termination] +seconds_spent_limit = 30 diff --git a/solverforge.app.toml b/solverforge.app.toml new file mode 100644 index 0000000000000000000000000000000000000000..40d3920d20ba0e5b2bfae847cec335174ad39a1e --- /dev/null +++ b/solverforge.app.toml @@ -0,0 +1,119 @@ +[app] +name = "solverforge-lessons" +starter = "neutral-shell" +cli_version = "2.0.4" + +[runtime] +target = "solverforge 0.13.1" +runtime_source = "crates.io: solverforge 0.13.1" +ui_source = "crates.io: solverforge-ui 0.6.5" + +[demo] +default_size = "LARGE" +available_sizes = [ + "LARGE", +] + +[solution] +name = "Plan" +score = "HardMediumSoftScore" + +[[facts]] +name = "timeslot" +plural = "timeslots" +kind = "problem_fact" + +[[facts]] +name = "teacher" +plural = "teachers" +kind = "problem_fact" + +[[facts]] +name = "group" +plural = "groups" +kind = "problem_fact" + +[[facts]] +name = "room" +plural = "rooms" +kind = "problem_fact" + +[[entities]] +name = "lesson" +plural = "lessons" +kind = "planning_entity" + +[[variables]] +entity = "lesson" +entity_plural = "lessons" +field = "timeslot_idx" +kind = "scalar" +range = "timeslots" +elements = "" +allows_unassigned = false +enabled = true + +[[variables]] +entity = "lesson" +entity_plural = "lessons" +field = "room_idx" +kind = "scalar" +range = "rooms" +elements = "" +allows_unassigned = false +enabled = true + +[[constraints]] +name = "assign_timeslot" +module = "assign_timeslot" +enabled = true + +[[constraints]] +name = "assign_room" +module = "assign_room" +enabled = true + +[[constraints]] +name = "teacher_availability" +module = "teacher_availability" +enabled = true + +[[constraints]] +name = "group_availability" +module = "group_availability" +enabled = true + +[[constraints]] +name = "room_kind" +module = "room_kind" +enabled = true + +[[constraints]] +name = "room_capacity" +module = "room_capacity" +enabled = true + +[[constraints]] +name = "no_group_conflict" +module = "no_group_conflict" +enabled = true + +[[constraints]] +name = "no_room_conflict" +module = "no_room_conflict" +enabled = true + +[[constraints]] +name = "no_teacher_conflict" +module = "no_teacher_conflict" +enabled = true + +[[constraints]] +name = "late_lesson" +module = "late_lesson" +enabled = true + +[[constraints]] +name = "repeated_subject_day" +module = "repeated_subject_day" +enabled = true diff --git a/src/api/dto.rs b/src/api/dto.rs new file mode 100644 index 0000000000000000000000000000000000000000..41b0e71306518251d9e1e77720b5d13c22c8cbe3 --- /dev/null +++ b/src/api/dto.rs @@ -0,0 +1,295 @@ +//! Browser-facing JSON types for the lessons API. +//! +//! The domain model is optimized for SolverForge joins and score calculation. +//! DTOs keep the HTTP contract stable and browser-friendly, including string +//! score labels and camelCase field names. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use solverforge::{ + HardMediumSoftScore, SolverLifecycleState, SolverSnapshot, SolverSnapshotAnalysis, + SolverStatus, SolverTelemetry, SolverTerminalReason, +}; +use std::time::Duration; + +use crate::domain::Plan; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanDto { + /// Flattened domain fields let the stock UI metadata describe facts and + /// entities without a hand-written transport struct for every collection. + #[serde(flatten)] + pub fields: Map, + #[serde(default)] + pub score: Option, +} + +/// One row in the browser's score-analysis panel. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConstraintAnalysisDto { + pub name: String, + pub weight: String, + pub score: String, + pub match_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalyzeResponse { + pub score: String, + pub constraints: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TelemetryDto { + pub elapsed_ms: u64, + pub step_count: u64, + pub moves_generated: u64, + pub moves_evaluated: u64, + pub moves_accepted: u64, + pub score_calculations: u64, + pub generation_ms: u64, + pub evaluation_ms: u64, + pub moves_per_second: u64, + pub acceptance_rate: f64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobSummaryDto { + pub id: String, + pub job_id: String, + pub lifecycle_state: &'static str, + pub terminal_reason: Option<&'static str>, + pub checkpoint_available: bool, + pub event_sequence: u64, + pub snapshot_revision: Option, + pub current_score: Option, + pub best_score: Option, + pub telemetry: TelemetryDto, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobSnapshotDto { + pub id: String, + pub job_id: String, + pub snapshot_revision: u64, + pub lifecycle_state: &'static str, + pub terminal_reason: Option<&'static str>, + pub current_score: Option, + pub best_score: Option, + pub telemetry: TelemetryDto, + pub solution: PlanDto, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobAnalysisDto { + pub id: String, + pub job_id: String, + pub snapshot_revision: u64, + pub lifecycle_state: &'static str, + pub terminal_reason: Option<&'static str>, + pub analysis: AnalyzeResponse, +} + +impl PlanDto { + pub fn from_plan(plan: &Plan) -> Self { + let mut fields = match serde_json::to_value(plan).expect("failed to serialize plan") { + Value::Object(map) => map, + _ => Map::new(), + }; + let score = fields.remove("score").and_then(|value| { + if value.is_null() { + None + } else if let Some(score) = value.as_str() { + Some(score.to_string()) + } else { + Some(value.to_string()) + } + }); + + Self { fields, score } + } + + pub fn to_domain(&self) -> Result { + let mut fields = self.fields.clone(); + let _ = &self.score; + fields.insert("score".to_string(), Value::Null); + let mut plan: Plan = serde_json::from_value(Value::Object(fields))?; + plan.rebuild_derived_fields(); + Ok(plan) + } +} + +impl TelemetryDto { + pub fn from_runtime(telemetry: SolverTelemetry) -> Self { + Self { + elapsed_ms: duration_to_millis(telemetry.elapsed), + step_count: telemetry.step_count, + moves_generated: telemetry.moves_generated, + moves_evaluated: telemetry.moves_evaluated, + moves_accepted: telemetry.moves_accepted, + score_calculations: telemetry.score_calculations, + generation_ms: duration_to_millis(telemetry.generation_time), + evaluation_ms: duration_to_millis(telemetry.evaluation_time), + moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed), + acceptance_rate: derive_acceptance_rate( + telemetry.moves_accepted, + telemetry.moves_evaluated, + ), + } + } +} + +impl JobSummaryDto { + pub fn from_status(job_id: usize, status: &SolverStatus) -> Self { + Self { + id: job_id.to_string(), + job_id: job_id.to_string(), + lifecycle_state: lifecycle_state_label(status.lifecycle_state), + terminal_reason: status.terminal_reason.map(terminal_reason_label), + checkpoint_available: status.checkpoint_available, + event_sequence: status.event_sequence, + snapshot_revision: status.latest_snapshot_revision, + current_score: status.current_score.map(|score| score.to_string()), + best_score: status.best_score.map(|score| score.to_string()), + telemetry: TelemetryDto::from_runtime(status.telemetry.clone()), + } + } +} + +impl JobSnapshotDto { + pub fn from_snapshot(snapshot: &SolverSnapshot) -> Self { + Self { + id: snapshot.job_id.to_string(), + job_id: snapshot.job_id.to_string(), + snapshot_revision: snapshot.snapshot_revision, + lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state), + terminal_reason: snapshot.terminal_reason.map(terminal_reason_label), + current_score: snapshot.current_score.map(|score| score.to_string()), + best_score: snapshot.best_score.map(|score| score.to_string()), + telemetry: TelemetryDto::from_runtime(snapshot.telemetry.clone()), + solution: PlanDto::from_plan(&snapshot.solution), + } + } +} + +impl JobAnalysisDto { + pub fn from_snapshot_analysis( + snapshot: &SolverSnapshotAnalysis, + analysis: AnalyzeResponse, + ) -> Self { + Self { + id: snapshot.job_id.to_string(), + job_id: snapshot.job_id.to_string(), + snapshot_revision: snapshot.snapshot_revision, + lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state), + terminal_reason: snapshot.terminal_reason.map(terminal_reason_label), + analysis, + } + } +} + +pub fn analysis_response( + analysis: &solverforge::ScoreAnalysis, +) -> AnalyzeResponse { + AnalyzeResponse { + score: analysis.score.to_string(), + constraints: analysis + .constraints + .iter() + .map(|constraint| ConstraintAnalysisDto { + name: constraint.name.clone(), + weight: constraint.weight.to_string(), + score: constraint.score.to_string(), + match_count: constraint.match_count, + }) + .collect(), + } +} + +pub fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str { + match state { + SolverLifecycleState::Solving => "SOLVING", + SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED", + SolverLifecycleState::Paused => "PAUSED", + SolverLifecycleState::Completed => "COMPLETED", + SolverLifecycleState::Cancelled => "CANCELLED", + SolverLifecycleState::Failed => "FAILED", + } +} + +pub fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str { + match reason { + SolverTerminalReason::Completed => "completed", + SolverTerminalReason::TerminatedByConfig => "terminated_by_config", + SolverTerminalReason::Cancelled => "cancelled", + SolverTerminalReason::Failed => "failed", + } +} + +fn duration_to_millis(duration: Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + +fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 { + let nanos = elapsed.as_nanos(); + if nanos == 0 { + 0 + } else { + let per_second = u128::from(count) + .saturating_mul(1_000_000_000) + .checked_div(nanos) + .unwrap_or(0); + per_second.min(u128::from(u64::MAX)) as u64 + } +} + +fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 { + if moves_evaluated == 0 { + 0.0 + } else { + moves_accepted as f64 / moves_evaluated as f64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::create_constraints; + use crate::data::{generate, DemoData}; + use solverforge::ConstraintSet; + + #[test] + fn plan_dto_round_trip_restores_lesson_indexes() { + let dto = PlanDto::from_plan(&generate(DemoData::Large)); + let plan = dto.to_domain().expect("DTO should decode into a plan"); + + for (index, lesson) in plan.lessons.iter().enumerate() { + assert_eq!(lesson.index, index); + } + } + + #[test] + fn plan_dto_round_trip_preserves_hard_conflict_detection() { + let mut plan = generate(DemoData::Large); + plan.lessons[0].timeslot_idx = Some(0); + plan.lessons[0].room_idx = Some(0); + plan.lessons[1].timeslot_idx = Some(0); + plan.lessons[1].room_idx = Some(0); + + let dto = PlanDto::from_plan(&plan); + let round_tripped = dto.to_domain().expect("DTO should decode into a plan"); + let score = create_constraints().evaluate_all(&round_tripped); + + assert!( + score.hard() < 0, + "round-tripped plan must still detect hard conflicts, got {score}" + ); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..54f1d1911adda4cd3cdba607b608010fae9e9a1a --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,11 @@ +//! HTTP transport surface for the lesson-timetabling app. +//! +//! Routes decode browser requests, DTOs define the JSON contract, and +//! `SolverService` owns retained jobs. + +mod dto; +mod routes; +mod sse; + +pub use dto::PlanDto; +pub use routes::{router, AppState}; diff --git a/src/api/routes.rs b/src/api/routes.rs new file mode 100644 index 0000000000000000000000000000000000000000..b434d67f752064ca97962fd282febe4e087da089 --- /dev/null +++ b/src/api/routes.rs @@ -0,0 +1,220 @@ +//! HTTP routes for the lesson-timetabling app. +//! +//! Handlers intentionally stay narrow: parse the route/query, ask the data or +//! retained solver service for the domain value, then return a DTO. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use super::dto::{analysis_response, JobAnalysisDto, JobSnapshotDto, JobSummaryDto, PlanDto}; +use super::sse; +use crate::data::{generate, DemoData}; +use crate::solver::SolverService; + +/// Shared application state stored once inside Axum. +pub struct AppState { + pub solver: SolverService, +} + +impl AppState { + pub fn new() -> Self { + Self { + solver: SolverService::new(), + } + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +/// Registers the public HTTP surface used by the browser and tests. +pub fn router(state: Arc) -> Router { + Router::new() + .route("/health", get(health)) + .route("/info", get(info)) + .route("/demo-data", get(list_demo_data)) + .route("/demo-data/{id}", get(get_demo_data)) + .route("/jobs", post(create_job)) + .route("/jobs/{id}", get(get_job).delete(delete_job)) + .route("/jobs/{id}/status", get(get_job_status)) + .route("/jobs/{id}/snapshot", get(get_snapshot)) + .route("/jobs/{id}/analysis", get(analyze_by_id)) + .route("/jobs/{id}/pause", post(pause_job)) + .route("/jobs/{id}/resume", post(resume_job)) + .route("/jobs/{id}/cancel", post(cancel_job)) + .route("/jobs/{id}/events", get(sse::events)) + .with_state(state) +} + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, +} + +async fn health() -> Json { + Json(HealthResponse { status: "UP" }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InfoResponse { + name: &'static str, + version: &'static str, + solver_engine: &'static str, +} + +async fn info() -> Json { + Json(InfoResponse { + name: env!("CARGO_PKG_NAME"), + version: env!("CARGO_PKG_VERSION"), + solver_engine: "SolverForge", + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DemoDataCatalogResponse { + default_id: &'static str, + available_ids: Vec<&'static str>, +} + +async fn list_demo_data() -> Json { + Json(DemoDataCatalogResponse { + default_id: DemoData::default_demo_data().id(), + available_ids: DemoData::available_demo_data() + .iter() + .map(|demo| demo.id()) + .collect(), + }) +} + +async fn get_demo_data(Path(id): Path) -> Result, StatusCode> { + let demo = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + let plan = generate(demo); + Ok(Json(PlanDto::from_plan(&plan))) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateJobResponse { + id: String, +} + +async fn create_job( + State(state): State>, + Json(dto): Json, +) -> Result, StatusCode> { + let plan = dto.to_domain().map_err(|_| StatusCode::BAD_REQUEST)?; + let id = state + .solver + .start_job(plan) + .map_err(status_from_solver_error)?; + Ok(Json(CreateJobResponse { id })) +} + +async fn get_job( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + let job_id = parse_job_id(&id)?; + let status = state + .solver + .get_status(&id) + .map_err(status_from_solver_error)?; + Ok(Json(JobSummaryDto::from_status(job_id, &status))) +} + +async fn get_job_status( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + get_job(State(state), Path(id)).await +} + +#[derive(Debug, Default, Deserialize)] +struct SnapshotQuery { + snapshot_revision: Option, +} + +async fn get_snapshot( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let snapshot = state + .solver + .get_snapshot(&id, query.snapshot_revision) + .map_err(status_from_solver_error)?; + Ok(Json(JobSnapshotDto::from_snapshot(&snapshot))) +} + +async fn analyze_by_id( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let snapshot_analysis = state + .solver + .analyze_snapshot(&id, query.snapshot_revision) + .map_err(status_from_solver_error)?; + let analysis = analysis_response(&snapshot_analysis.analysis); + Ok(Json(JobAnalysisDto::from_snapshot_analysis( + &snapshot_analysis, + analysis, + ))) +} + +async fn pause_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.pause(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +async fn resume_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.resume(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +async fn cancel_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.cancel(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::ACCEPTED) +} + +async fn delete_job( + State(state): State>, + Path(id): Path, +) -> Result { + state.solver.delete(&id).map_err(status_from_solver_error)?; + Ok(StatusCode::NO_CONTENT) +} + +fn parse_job_id(id: &str) -> Result { + id.parse::().map_err(|_| StatusCode::NOT_FOUND) +} + +fn status_from_solver_error(error: solverforge::SolverManagerError) -> StatusCode { + match error { + solverforge::SolverManagerError::NoFreeJobSlots => StatusCode::SERVICE_UNAVAILABLE, + solverforge::SolverManagerError::JobNotFound { .. } => StatusCode::NOT_FOUND, + solverforge::SolverManagerError::InvalidStateTransition { .. } => StatusCode::CONFLICT, + solverforge::SolverManagerError::NoSnapshotAvailable { .. } => StatusCode::CONFLICT, + solverforge::SolverManagerError::SnapshotNotFound { .. } => StatusCode::NOT_FOUND, + } +} diff --git a/src/api/sse.rs b/src/api/sse.rs new file mode 100644 index 0000000000000000000000000000000000000000..c0ccd0ecc4af6729badd73126b7e935db4e16594 --- /dev/null +++ b/src/api/sse.rs @@ -0,0 +1,74 @@ +//! Server-sent events for retained lesson solve jobs. +//! +//! The first frame is a bootstrap status or snapshot so late subscribers can +//! render immediately. Later frames come from the job's broadcast channel. + +use axum::{ + body::Body, + extract::{Path, State}, + http::{header, StatusCode}, + response::Response, +}; +use std::sync::Arc; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::StreamExt; + +use super::routes::AppState; + +pub async fn events( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + let rx = state.solver.subscribe(&id).ok_or(StatusCode::NOT_FOUND)?; + let bootstrap_json = state + .solver + .bootstrap_event(&id) + .map_err(|_| StatusCode::NOT_FOUND)?; + let bootstrap_event_sequence = event_sequence_from_json(&bootstrap_json); + let bootstrap = tokio_stream::iter(std::iter::once(Ok::<_, std::convert::Infallible>( + format!("data: {}\n\n", bootstrap_json).into_bytes(), + ))); + + let live = BroadcastStream::new(rx).filter_map(move |msg| match msg { + Ok(json) => { + if event_is_not_newer(&json, bootstrap_event_sequence) { + return None; + } + Some(Ok::<_, std::convert::Infallible>( + format!("data: {}\n\n", json).into_bytes(), + )) + } + // Broadcast channels can report that a slow browser missed events. The + // next retained snapshot/status request is still authoritative, so the + // stream drops that gap instead of failing the connection. + Err(_) => None, + }); + + let stream = bootstrap.chain(live); + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header("X-Accel-Buffering", "no") + .body(Body::from_stream(stream)) + .unwrap()) +} + +fn event_sequence_from_json(json: &str) -> Option { + serde_json::from_str::(json) + .ok() + .and_then(|value| { + value + .get("eventSequence") + .and_then(serde_json::Value::as_u64) + }) +} + +/// Returns true when a live event is already covered by the bootstrap frame. +fn event_is_not_newer(json: &str, bootstrap_event_sequence: Option) -> bool { + let Some(bootstrap_event_sequence) = bootstrap_event_sequence else { + return false; + }; + event_sequence_from_json(json) + .is_some_and(|event_sequence| event_sequence <= bootstrap_event_sequence) +} diff --git a/src/constraints/assign_room.rs b/src/constraints/assign_room.rs new file mode 100644 index 0000000000000000000000000000000000000000..b090c6e8840b312e493dfb531ff03b3aa49b289a --- /dev/null +++ b/src/constraints/assign_room.rs @@ -0,0 +1,52 @@ +use crate::domain::{Lesson, Plan}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// MEDIUM: Every lesson should receive a room. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .for_each(Plan::lessons()) + .filter(|lesson: &Lesson| lesson.room_idx.is_none()) + .penalize(|_: &Lesson| HardMediumSoftScore::of_medium(1)) + .named("Assign Room") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Room, Timeslot, Weekday}; + use chrono::NaiveTime; + + fn time(hour: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, 0, 0).unwrap() + } + + fn plan_with_room_assignment(room_idx: Option) -> Plan { + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))]; + let rooms = vec![Room::new(0, "Room A")]; + let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)]; + lessons[0].room_idx = room_idx; + Plan::new(timeslots, vec![], vec![], lessons, rooms) + } + + fn evaluate_only_assign_room(plan: &Plan) -> HardMediumSoftScore { + let constraint_set = (constraint(),); + constraint_set.evaluate_all(plan) + } + + #[test] + fn penalizes_unassigned_room_at_medium_level() { + let plan = plan_with_room_assignment(None); + let score = evaluate_only_assign_room(&plan); + + assert_eq!(score, HardMediumSoftScore::of_medium(-1)); + } + + #[test] + fn assigned_room_has_zero_score() { + let plan = plan_with_room_assignment(Some(0)); + let score = evaluate_only_assign_room(&plan); + + assert_eq!(score, HardMediumSoftScore::ZERO); + } +} diff --git a/src/constraints/assign_timeslot.rs b/src/constraints/assign_timeslot.rs new file mode 100644 index 0000000000000000000000000000000000000000..c4ee9d3eeb06671e302739ea2896a8db00e67788 --- /dev/null +++ b/src/constraints/assign_timeslot.rs @@ -0,0 +1,52 @@ +use crate::domain::{Lesson, Plan}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// MEDIUM: Every lesson should receive a timeslot. +pub fn constraint() -> impl IncrementalConstraint { + ConstraintFactory::::new() + .for_each(Plan::lessons()) + .filter(|lesson: &Lesson| lesson.timeslot_idx.is_none()) + .penalize(|_: &Lesson| HardMediumSoftScore::of_medium(1)) + .named("Assign Timeslot") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Room, Timeslot, Weekday}; + use chrono::NaiveTime; + + fn time(hour: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, 0, 0).unwrap() + } + + fn plan_with_timeslot_assignment(timeslot_idx: Option) -> Plan { + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))]; + let rooms = vec![Room::new(0, "Room A")]; + let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)]; + lessons[0].timeslot_idx = timeslot_idx; + Plan::new(timeslots, vec![], vec![], lessons, rooms) + } + + fn evaluate_only_assign_timeslot(plan: &Plan) -> HardMediumSoftScore { + let constraint_set = (constraint(),); + constraint_set.evaluate_all(plan) + } + + #[test] + fn penalizes_unassigned_timeslot_at_medium_level() { + let plan = plan_with_timeslot_assignment(None); + let score = evaluate_only_assign_timeslot(&plan); + + assert_eq!(score, HardMediumSoftScore::of_medium(-1)); + } + + #[test] + fn assigned_timeslot_has_zero_score() { + let plan = plan_with_timeslot_assignment(Some(0)); + let score = evaluate_only_assign_timeslot(&plan); + + assert_eq!(score, HardMediumSoftScore::ZERO); + } +} diff --git a/src/constraints/group_availability.rs b/src/constraints/group_availability.rs new file mode 100644 index 0000000000000000000000000000000000000000..f446d0e3a8e40360a9f67cf783c44a300901a121 --- /dev/null +++ b/src/constraints/group_availability.rs @@ -0,0 +1,77 @@ +use crate::domain::{Group, Lesson, Plan}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: Cohorts can only attend lessons in slots where they are available. +pub fn constraint() -> impl IncrementalConstraint { + use solverforge::stream::joiner::equal_bi; + + ConstraintFactory::::new() + .for_each(Plan::lessons()) + .join(( + ConstraintFactory::::new().for_each(Plan::groups()), + equal_bi( + |lesson: &Lesson| lesson.group_idx, + |group: &Group| group.index, + ), + )) + .filter(|lesson: &Lesson, group: &Group| { + lesson.timeslot_idx.is_some_and(|timeslot_idx| { + !group + .availability + .get(timeslot_idx) + .copied() + .unwrap_or(false) + }) + }) + .penalize(hard_weight(|_: &Lesson, _: &Group| { + HardMediumSoftScore::of_hard(1) + })) + .named("Group Availability") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Room, Timeslot, Weekday}; + use chrono::NaiveTime; + use solverforge::ConstraintSet; + + fn time(hour: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, 0, 0).unwrap() + } + + fn score_for_group_availability(available: bool, assigned: bool) -> HardMediumSoftScore { + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))]; + let groups = vec![Group::new(0, "Group A", 30, vec![available])]; + let rooms = vec![Room::new(0, "Room A")]; + let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)]; + if assigned { + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + } + let plan = Plan::new(timeslots, vec![], groups, lessons, rooms); + + (constraint(),).evaluate_all(&plan) + } + + #[test] + fn penalizes_assigned_unavailable_group() { + assert_eq!( + score_for_group_availability(false, true), + HardMediumSoftScore::of_hard(-1) + ); + } + + #[test] + fn ignores_available_or_unassigned_group_slots() { + assert_eq!( + score_for_group_availability(true, true), + HardMediumSoftScore::ZERO + ); + assert_eq!( + score_for_group_availability(false, false), + HardMediumSoftScore::ZERO + ); + } +} diff --git a/src/constraints/late_lesson.rs b/src/constraints/late_lesson.rs new file mode 100644 index 0000000000000000000000000000000000000000..0060c718e1ce23d767b024e2ee3fca8c73b779a5 --- /dev/null +++ b/src/constraints/late_lesson.rs @@ -0,0 +1,64 @@ +use crate::domain::{Lesson, Plan, Timeslot}; +use chrono::{NaiveTime, Timelike}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// SOFT: Prefer lessons before the late afternoon. +pub fn constraint() -> impl IncrementalConstraint { + use solverforge::stream::joiner::equal_bi; + + ConstraintFactory::::new() + .for_each(Plan::lessons()) + .join(( + ConstraintFactory::::new().for_each(Plan::timeslots()), + equal_bi( + |lesson: &Lesson| lesson.timeslot_idx, + |timeslot: &Timeslot| Some(timeslot.index), + ), + )) + .filter(|_lesson: &Lesson, timeslot: &Timeslot| is_late_slot(timeslot.start_time)) + .penalize(|_: &Lesson, _: &Timeslot| HardMediumSoftScore::of_soft(1)) + .named("Avoid Late Lessons") +} + +fn is_late_slot(start_time: NaiveTime) -> bool { + start_time.hour() >= 15 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Room, Weekday}; + use solverforge::ConstraintSet; + + fn time(hour: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, 0, 0).unwrap() + } + + fn score_for_start_hour(hour: u32, assigned: bool) -> HardMediumSoftScore { + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(hour), time(hour + 1))]; + let rooms = vec![Room::new(0, "Room A")]; + let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)]; + if assigned { + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + } + let plan = Plan::new(timeslots, vec![], vec![], lessons, rooms); + + (constraint(),).evaluate_all(&plan) + } + + #[test] + fn penalizes_late_assigned_lessons() { + assert_eq!( + score_for_start_hour(15, true), + HardMediumSoftScore::of_soft(-1) + ); + } + + #[test] + fn ignores_early_or_unassigned_lessons() { + assert_eq!(score_for_start_hour(14, true), HardMediumSoftScore::ZERO); + assert_eq!(score_for_start_hour(15, false), HardMediumSoftScore::ZERO); + } +} diff --git a/src/constraints/mod.rs b/src/constraints/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..9fd5608f20f9bd13fe0e881c01c030c3745401bb --- /dev/null +++ b/src/constraints/mod.rs @@ -0,0 +1,48 @@ +#![cfg_attr(rustfmt, rustfmt_skip)] +//! Constraint assembly for lesson timetabling. +//! +//! Each child module owns one named timetable rule. `create_constraints()` +//! lists them in the order beginners should read the score analysis: assignment +//! completeness first, hard feasibility next, soft timetable quality last. + +use crate::domain::Plan; +use solverforge::prelude::*; + +pub use self::assemble::create_constraints; + +// @solverforge:begin constraint-modules +mod assign_room; +mod assign_timeslot; +mod group_availability; +mod late_lesson; +mod no_group_conflict; +mod no_room_conflict; +mod no_teacher_conflict; +mod repeated_subject_day; +mod room_capacity; +mod room_kind; +mod teacher_availability; +// @solverforge:end constraint-modules + +mod assemble { + use super::*; + + /// Collects the full scoring model used by `Plan`. + pub fn create_constraints() -> impl ConstraintSet { + // @solverforge:begin constraint-calls + ( + assign_timeslot::constraint(), + assign_room::constraint(), + teacher_availability::constraint(), + group_availability::constraint(), + room_kind::constraint(), + room_capacity::constraint(), + no_group_conflict::constraint(), + no_teacher_conflict::constraint(), + no_room_conflict::constraint(), + late_lesson::constraint(), + repeated_subject_day::constraint(), + ) + // @solverforge:end constraint-calls + } +} diff --git a/src/constraints/no_group_conflict.rs b/src/constraints/no_group_conflict.rs new file mode 100644 index 0000000000000000000000000000000000000000..6ddb0a063aeb5dfd5999cc669547719f22c0110e --- /dev/null +++ b/src/constraints/no_group_conflict.rs @@ -0,0 +1,173 @@ +use crate::domain::{Lesson, Plan, Timeslot, Weekday}; +use chrono::NaiveTime; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: No two lessons for the same group can overlap in time. +struct AssignedLessonSlot { + lesson_index: usize, + group_idx: usize, + day_of_week: Weekday, + start_time: NaiveTime, + end_time: NaiveTime, +} + +pub fn constraint() -> impl IncrementalConstraint { + use solverforge::stream::joiner::{equal, equal_bi}; + + ConstraintFactory::::new() + .for_each(Plan::lessons()) + // First attach the assigned timeslot to each lesson. Unassigned + // lessons do not join here, so this rule does not duplicate the medium + // assignment penalty. + .join(( + ConstraintFactory::::new().for_each(Plan::timeslots()), + equal_bi( + |lesson: &Lesson| lesson.timeslot_idx, + |timeslot: &Timeslot| Some(timeslot.index), + ), + )) + // Reduce each joined row to the fields needed to detect a cohort + // collision. This keeps the later pairwise join small and readable. + .project(|lesson: &Lesson, timeslot: &Timeslot| AssignedLessonSlot { + lesson_index: lesson.index, + group_idx: lesson.group_idx, + day_of_week: timeslot.day_of_week, + start_time: timeslot.start_time, + end_time: timeslot.end_time, + }) + .join(equal(|row: &AssignedLessonSlot| row.group_idx)) + // `lesson_index < b.lesson_index` avoids scoring the same conflicting + // pair twice. The strict time comparisons implement ordinary interval + // overlap, so a lesson ending at 10:00 does not conflict with one + // starting at 10:00. + .filter(|a: &AssignedLessonSlot, b: &AssignedLessonSlot| { + a.lesson_index < b.lesson_index + && a.day_of_week == b.day_of_week + && a.start_time < b.end_time + && b.start_time < a.end_time + }) + .penalize(hard_weight( + |_: &AssignedLessonSlot, _: &AssignedLessonSlot| HardMediumSoftScore::of_hard(1), + )) + .named("No Group Conflict") +} + +#[cfg(test)] +mod tests { + use crate::domain::Group; + + use super::*; + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + // Helper that only runs the No Group Conflict constraint + fn evaluate_only_group_conflict(plan: &Plan) -> HardMediumSoftScore { + let constraint_set = (constraint(),); + constraint_set.evaluate_all(plan) + } + + #[test] + fn detects_group_conflict_same_timeslot() { + let groups = vec![Group::new(0, "Group A", 30, [true; 10])]; + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; + let teachers = vec![]; + let rooms = vec![]; + // Same group, no teacher assignment to avoid teacher conflict, no room assignment to avoid room conflict + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 0, None, 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[1].timeslot_idx = Some(0); + + let plan = Plan::new(timeslots, teachers, groups, lessons, rooms); + let score = evaluate_only_group_conflict(&plan); + + assert_eq!( + score.hard(), + -1, + "Expected hard score -1 for group conflict" + ); + } + + #[test] + fn detects_group_conflict_overlapping_timeslots() { + let groups = vec![Group::new(0, "Group A", 30, [true; 10])]; + let timeslots = vec![ + Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)), + Timeslot::new(1, Weekday::Mon, time(9, 0), time(11, 0)), + ]; + let teachers = vec![]; + let rooms = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 0, None, 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[1].timeslot_idx = Some(1); + + let plan = Plan::new(timeslots, teachers, groups, lessons, rooms); + let score = evaluate_only_group_conflict(&plan); + + assert_eq!( + score.hard(), + -1, + "Expected hard score -1 for overlapping group conflict" + ); + } + + #[test] + fn no_conflict_different_groups() { + let groups = vec![ + Group::new(0, "Group A", 30, [true; 10]), + Group::new(1, "Group B", 30, [true; 10]), + ]; + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; + let teachers = vec![]; + let rooms = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 1, None, 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[1].timeslot_idx = Some(0); + + let plan = Plan::new(timeslots, teachers, groups, lessons, rooms); + let score = evaluate_only_group_conflict(&plan); + + assert_eq!( + score.hard(), + 0, + "Expected hard score 0 for different groups" + ); + } + + #[test] + fn no_conflict_non_overlapping_timeslots() { + let groups = vec![Group::new(0, "Group A", 30, [true; 10])]; + let timeslots = vec![ + Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)), + Timeslot::new(1, Weekday::Mon, time(10, 0), time(12, 0)), + ]; + let teachers = vec![]; + let rooms = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 0, None, 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[1].timeslot_idx = Some(1); + + let plan = Plan::new(timeslots, teachers, groups, lessons, rooms); + let score = evaluate_only_group_conflict(&plan); + + assert_eq!( + score.hard(), + 0, + "Expected hard score 0 for non-overlapping timeslots" + ); + } +} diff --git a/src/constraints/no_room_conflict.rs b/src/constraints/no_room_conflict.rs new file mode 100644 index 0000000000000000000000000000000000000000..bcfd2de7b46fe390a85318e18c4d284d64f81ec4 --- /dev/null +++ b/src/constraints/no_room_conflict.rs @@ -0,0 +1,186 @@ +use crate::domain::{Lesson, Plan, Timeslot, Weekday}; +use chrono::NaiveTime; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: No two lessons in the same room can overlap in time. +struct AssignedLessonSlot { + lesson_index: usize, + room_idx: usize, + day_of_week: Weekday, + start_time: NaiveTime, + end_time: NaiveTime, +} + +pub fn constraint() -> impl IncrementalConstraint { + use solverforge::stream::joiner::{equal, equal_bi}; + + ConstraintFactory::::new() + .for_each(Plan::lessons()) + // First attach the assigned timeslot to each lesson. Unassigned + // lessons do not join here, so this rule does not duplicate the medium + // assignment penalty. + .join(( + ConstraintFactory::::new().for_each(Plan::timeslots()), + equal_bi( + |lesson: &Lesson| lesson.timeslot_idx, + |timeslot: &Timeslot| Some(timeslot.index), + ), + )) + // Reduce each joined row to the fields needed to detect a room + // collision. This keeps the later pairwise join small and readable. + .project(|lesson: &Lesson, timeslot: &Timeslot| AssignedLessonSlot { + lesson_index: lesson.index, + room_idx: lesson.room_idx.unwrap_or(usize::MAX), + day_of_week: timeslot.day_of_week, + start_time: timeslot.start_time, + end_time: timeslot.end_time, + }) + .join(equal(|row: &AssignedLessonSlot| row.room_idx)) + // `lesson_index < b.lesson_index` avoids scoring the same conflicting + // pair twice. The strict time comparisons implement ordinary interval + // overlap, so a lesson ending at 10:00 does not conflict with one + // starting at 10:00. + .filter(|a: &AssignedLessonSlot, b: &AssignedLessonSlot| { + a.lesson_index < b.lesson_index + && a.day_of_week == b.day_of_week + && a.start_time < b.end_time + && b.start_time < a.end_time + }) + .penalize(hard_weight( + |_: &AssignedLessonSlot, _: &AssignedLessonSlot| HardMediumSoftScore::of_hard(1), + )) + .named("No Room Conflict") +} + +#[cfg(test)] +mod tests { + use crate::domain::Room; + + use super::*; + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + // Helper that only runs the No Room Conflict constraint + fn evaluate_only_room_conflict(plan: &Plan) -> HardMediumSoftScore { + let constraint_set = (constraint(),); + constraint_set.evaluate_all(plan) + } + + #[test] + fn detects_room_conflict_same_timeslot() { + let rooms = vec![Room::new(0, "Room A")]; + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; + let teachers = vec![]; + // Different groups to avoid group conflict, no teacher assignment to avoid teacher conflict + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 1, None, 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + lessons[1].timeslot_idx = Some(0); + lessons[1].room_idx = Some(0); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_room_conflict(&plan); + + assert_eq!(score.hard(), -1, "Expected hard score -1 for room conflict"); + } + + #[test] + fn detects_room_conflict_overlapping_timeslots() { + let rooms = vec![Room::new(0, "Room A")]; + let timeslots = vec![ + Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)), + Timeslot::new(1, Weekday::Mon, time(9, 0), time(11, 0)), + ]; + let teachers = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 1, None, 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + lessons[1].timeslot_idx = Some(1); + lessons[1].room_idx = Some(0); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_room_conflict(&plan); + + assert_eq!( + score.hard(), + -1, + "Expected hard score -1 for overlapping room conflict" + ); + } + + #[test] + fn no_conflict_different_rooms() { + let rooms = vec![Room::new(0, "Room A"), Room::new(1, "Room B")]; + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; + let teachers = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 0, None, 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + lessons[1].timeslot_idx = Some(0); + lessons[1].room_idx = Some(1); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_room_conflict(&plan); + + assert_eq!(score.hard(), 0, "Expected hard score 0 for different rooms"); + } + + #[test] + fn no_conflict_non_overlapping_timeslots() { + let rooms = vec![Room::new(0, "Room A")]; + let timeslots = vec![ + Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)), + Timeslot::new(1, Weekday::Mon, time(10, 0), time(12, 0)), + ]; + let teachers = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 0, None, 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + lessons[1].timeslot_idx = Some(1); + lessons[1].room_idx = Some(0); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_room_conflict(&plan); + + assert_eq!( + score.hard(), + 0, + "Expected hard score 0 for non-overlapping timeslots" + ); + } + + #[test] + fn no_conflict_unassigned_room() { + let rooms = vec![Room::new(0, "Room A")]; + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; + let teachers = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 1, None, 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = None; // Unassigned + lessons[1].timeslot_idx = Some(0); + lessons[1].room_idx = Some(0); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_room_conflict(&plan); + + assert_eq!(score.hard(), 0, "Expected hard score 0 for unassigned room"); + } +} diff --git a/src/constraints/no_teacher_conflict.rs b/src/constraints/no_teacher_conflict.rs new file mode 100644 index 0000000000000000000000000000000000000000..29bb56a9ad1c2aea972d1a6550303d8c0930a464 --- /dev/null +++ b/src/constraints/no_teacher_conflict.rs @@ -0,0 +1,193 @@ +use crate::domain::{Lesson, Plan, Timeslot, Weekday}; +use chrono::NaiveTime; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: No two lessons with the same teacher can overlap in time. +struct AssignedLessonSlot { + lesson_index: usize, + teacher_idx: usize, + day_of_week: Weekday, + start_time: NaiveTime, + end_time: NaiveTime, +} + +pub fn constraint() -> impl IncrementalConstraint { + use solverforge::stream::joiner::{equal, equal_bi}; + + ConstraintFactory::::new() + .for_each(Plan::lessons()) + // First attach the assigned timeslot to each lesson. Unassigned + // lessons do not join here, so this rule does not duplicate the medium + // assignment penalty. + .join(( + ConstraintFactory::::new().for_each(Plan::timeslots()), + equal_bi( + |lesson: &Lesson| lesson.timeslot_idx, + |timeslot: &Timeslot| Some(timeslot.index), + ), + )) + // Reduce each joined row to the fields needed to detect a teacher + // collision. This keeps the later pairwise join small and readable. + .project(|lesson: &Lesson, timeslot: &Timeslot| AssignedLessonSlot { + lesson_index: lesson.index, + teacher_idx: lesson.teacher_idx.unwrap_or(usize::MAX), + day_of_week: timeslot.day_of_week, + start_time: timeslot.start_time, + end_time: timeslot.end_time, + }) + .join(equal(|row: &AssignedLessonSlot| row.teacher_idx)) + // `lesson_index < b.lesson_index` avoids scoring the same conflicting + // pair twice. The strict time comparisons implement ordinary interval + // overlap, so a lesson ending at 10:00 does not conflict with one + // starting at 10:00. + .filter(|a: &AssignedLessonSlot, b: &AssignedLessonSlot| { + a.lesson_index < b.lesson_index + && a.day_of_week == b.day_of_week + && a.start_time < b.end_time + && b.start_time < a.end_time + }) + .penalize(hard_weight( + |_: &AssignedLessonSlot, _: &AssignedLessonSlot| HardMediumSoftScore::of_hard(1), + )) + .named("No Teacher Conflict") +} + +#[cfg(test)] +mod tests { + use crate::domain::Teacher; + + use super::*; + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + // Helper that only runs the No Teacher Conflict constraint + fn evaluate_only_teacher_conflict(plan: &Plan) -> HardMediumSoftScore { + // Create a constraint set with only the teacher conflict constraint + // Using a tuple to implement ConstraintSet + let constraint_set = (constraint(),); + constraint_set.evaluate_all(plan) + } + + #[test] + fn detects_teacher_conflict_same_timeslot() { + let teachers = vec![Teacher::new(0, "Prof A", [true; 10])]; + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; + let rooms = vec![]; + // Different groups to avoid group conflict, no room assignment to avoid room conflict + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, Some(0), 120), + Lesson::new(1, "Physics".to_string(), 1, Some(0), 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[1].timeslot_idx = Some(0); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_teacher_conflict(&plan); + + assert_eq!( + score.hard(), + -1, + "Expected hard score -1 for teacher conflict" + ); + } + + #[test] + fn detects_teacher_conflict_overlapping_timeslots() { + let teachers = vec![Teacher::new(0, "Prof A", [true; 10])]; + let timeslots = vec![ + Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)), + Timeslot::new(1, Weekday::Mon, time(9, 0), time(11, 0)), + ]; + let rooms = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, Some(0), 120), + Lesson::new(1, "Physics".to_string(), 1, Some(0), 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[1].timeslot_idx = Some(1); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_teacher_conflict(&plan); + + assert_eq!( + score.hard(), + -1, + "Expected hard score -1 for overlapping teacher conflict" + ); + } + + #[test] + fn no_conflict_different_teachers() { + let teachers = vec![ + Teacher::new(0, "Prof A", [true; 10]), + Teacher::new(1, "Prof B", [true; 10]), + ]; + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; + let rooms = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, Some(0), 120), + Lesson::new(1, "Physics".to_string(), 0, Some(1), 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[1].timeslot_idx = Some(0); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_teacher_conflict(&plan); + + assert_eq!( + score.hard(), + 0, + "Expected hard score 0 for different teachers" + ); + } + + #[test] + fn no_conflict_non_overlapping_timeslots() { + let teachers = vec![Teacher::new(0, "Prof A", [true; 10])]; + let timeslots = vec![ + Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)), + Timeslot::new(1, Weekday::Mon, time(10, 0), time(12, 0)), + ]; + let rooms = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, Some(0), 120), + Lesson::new(1, "Physics".to_string(), 0, Some(0), 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[1].timeslot_idx = Some(1); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_teacher_conflict(&plan); + + assert_eq!( + score.hard(), + 0, + "Expected hard score 0 for non-overlapping timeslots" + ); + } + + #[test] + fn no_conflict_unassigned_teacher() { + let teachers = vec![Teacher::new(0, "Prof A", [true; 10])]; + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; + let rooms = vec![]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 1, Some(0), 120), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[1].timeslot_idx = Some(0); + + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + let score = evaluate_only_teacher_conflict(&plan); + + assert_eq!( + score.hard(), + 0, + "Expected hard score 0 for unassigned teacher" + ); + } +} diff --git a/src/constraints/repeated_subject_day.rs b/src/constraints/repeated_subject_day.rs new file mode 100644 index 0000000000000000000000000000000000000000..f232a885c053b164d44eec6428f16b18189467ee --- /dev/null +++ b/src/constraints/repeated_subject_day.rs @@ -0,0 +1,86 @@ +use crate::domain::{Lesson, Plan, Timeslot, Weekday}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// SOFT: Prefer not to schedule the same subject twice in one day for a cohort. +struct LessonDay { + lesson_index: usize, + group_idx: usize, + subject: String, + day_of_week: Weekday, +} + +pub fn constraint() -> impl IncrementalConstraint { + use solverforge::stream::joiner::{equal, equal_bi}; + + ConstraintFactory::::new() + .for_each(Plan::lessons()) + .join(( + ConstraintFactory::::new().for_each(Plan::timeslots()), + equal_bi( + |lesson: &Lesson| lesson.timeslot_idx, + |timeslot: &Timeslot| Some(timeslot.index), + ), + )) + .project(|lesson: &Lesson, timeslot: &Timeslot| LessonDay { + lesson_index: lesson.index, + group_idx: lesson.group_idx, + subject: lesson.subject.clone(), + day_of_week: timeslot.day_of_week, + }) + .join(equal(|row: &LessonDay| row.group_idx)) + .filter(|a: &LessonDay, b: &LessonDay| { + a.lesson_index < b.lesson_index + && a.day_of_week == b.day_of_week + && a.subject == b.subject + }) + .penalize(|_: &LessonDay, _: &LessonDay| HardMediumSoftScore::of_soft(1)) + .named("Avoid Repeated Subject Day") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::Room; + use chrono::NaiveTime; + use solverforge::ConstraintSet; + + fn time(hour: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, 0, 0).unwrap() + } + + fn score_for_subject_days(second_day: Weekday) -> HardMediumSoftScore { + let timeslots = vec![ + Timeslot::new(0, Weekday::Mon, time(8), time(9)), + Timeslot::new(1, second_day, time(9), time(10)), + ]; + let rooms = vec![Room::new(0, "Room A")]; + let mut lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 60), + Lesson::new(1, "Math".to_string(), 0, None, 60), + ]; + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + lessons[1].timeslot_idx = Some(1); + lessons[1].room_idx = Some(0); + let plan = Plan::new(timeslots, vec![], vec![], lessons, rooms); + + (constraint(),).evaluate_all(&plan) + } + + #[test] + fn penalizes_repeated_subject_on_same_day() { + assert_eq!( + score_for_subject_days(Weekday::Mon), + HardMediumSoftScore::of_soft(-1) + ); + } + + #[test] + fn ignores_same_subject_on_different_days() { + assert_eq!( + score_for_subject_days(Weekday::Tue), + HardMediumSoftScore::ZERO + ); + } +} diff --git a/src/constraints/room_capacity.rs b/src/constraints/room_capacity.rs new file mode 100644 index 0000000000000000000000000000000000000000..900577ff76c6d7fbfcefacc82e1fd68b67d6d74f --- /dev/null +++ b/src/constraints/room_capacity.rs @@ -0,0 +1,74 @@ +use crate::domain::{Lesson, Plan, Room}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: A room must be large enough for the cohort assigned to it. +pub fn constraint() -> impl IncrementalConstraint { + use solverforge::stream::joiner::equal_bi; + + ConstraintFactory::::new() + .for_each(Plan::lessons()) + .join(( + ConstraintFactory::::new().for_each(Plan::rooms()), + equal_bi( + |lesson: &Lesson| lesson.room_idx, + |room: &Room| Some(room.index), + ), + )) + .filter(|lesson: &Lesson, room: &Room| lesson.student_count > room.capacity) + .penalize(hard_weight(|_: &Lesson, _: &Room| { + HardMediumSoftScore::of_hard(1) + })) + .named("Room Capacity") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Group, RoomKind, Timeslot, Weekday}; + use chrono::NaiveTime; + use solverforge::ConstraintSet; + + fn time(hour: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, 0, 0).unwrap() + } + + fn score_for_room_capacity(capacity: usize, assigned: bool) -> HardMediumSoftScore { + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))]; + let groups = vec![Group::new(0, "Group A", 30, vec![true])]; + let rooms = vec![Room::with_kind_capacity( + 0, + "Room A", + RoomKind::Lecture, + capacity, + )]; + let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, None, 60)]; + if assigned { + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + } + let plan = Plan::new(timeslots, vec![], groups, lessons, rooms); + + (constraint(),).evaluate_all(&plan) + } + + #[test] + fn penalizes_room_under_capacity() { + assert_eq!( + score_for_room_capacity(24, true), + HardMediumSoftScore::of_hard(-1) + ); + } + + #[test] + fn ignores_sufficient_or_unassigned_room_capacity() { + assert_eq!( + score_for_room_capacity(30, true), + HardMediumSoftScore::ZERO + ); + assert_eq!( + score_for_room_capacity(24, false), + HardMediumSoftScore::ZERO + ); + } +} diff --git a/src/constraints/room_kind.rs b/src/constraints/room_kind.rs new file mode 100644 index 0000000000000000000000000000000000000000..dd687d3128880e7bb06cf75dfad3f9046f2b969a --- /dev/null +++ b/src/constraints/room_kind.rs @@ -0,0 +1,73 @@ +use crate::domain::{Lesson, Plan, Room}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// SOFT: Prefer assigning lessons to rooms that support the subject. +pub fn constraint() -> impl IncrementalConstraint { + use solverforge::stream::joiner::equal_bi; + + ConstraintFactory::::new() + .for_each(Plan::lessons()) + .join(( + ConstraintFactory::::new().for_each(Plan::rooms()), + equal_bi( + |lesson: &Lesson| lesson.room_idx, + |room: &Room| Some(room.index), + ), + )) + .filter(|lesson: &Lesson, room: &Room| lesson.required_room_kind != room.kind) + .penalize(|_: &Lesson, _: &Room| HardMediumSoftScore::of_soft(1)) + .named("Room Kind") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{RoomKind, Timeslot, Weekday}; + use chrono::NaiveTime; + use solverforge::ConstraintSet; + + fn time(hour: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, 0, 0).unwrap() + } + + fn score_for_room_kind(room_kind: RoomKind, assigned: bool) -> HardMediumSoftScore { + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))]; + let rooms = vec![Room::with_kind_capacity(0, "Room A", room_kind, 40)]; + let mut lessons = vec![Lesson::with_required_room_kind( + 0, + "Chemistry".to_string(), + 0, + None, + 60, + RoomKind::Lab, + )]; + if assigned { + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + } + let plan = Plan::new(timeslots, vec![], vec![], lessons, rooms); + + (constraint(),).evaluate_all(&plan) + } + + #[test] + fn penalizes_room_kind_mismatch() { + assert_eq!( + score_for_room_kind(RoomKind::Lecture, true), + HardMediumSoftScore::of_soft(-1) + ); + } + + #[test] + fn ignores_matching_or_unassigned_rooms() { + assert_eq!( + score_for_room_kind(RoomKind::Lab, true), + HardMediumSoftScore::ZERO + ); + assert_eq!( + score_for_room_kind(RoomKind::Lecture, false), + HardMediumSoftScore::ZERO + ); + } +} diff --git a/src/constraints/teacher_availability.rs b/src/constraints/teacher_availability.rs new file mode 100644 index 0000000000000000000000000000000000000000..0d8f71c9ea2a139b63b875f98318aefcc44acfdf --- /dev/null +++ b/src/constraints/teacher_availability.rs @@ -0,0 +1,77 @@ +use crate::domain::{Lesson, Plan, Teacher}; +use solverforge::prelude::*; +use solverforge::IncrementalConstraint; + +/// HARD: Teachers can only teach in slots where they are available. +pub fn constraint() -> impl IncrementalConstraint { + use solverforge::stream::joiner::equal_bi; + + ConstraintFactory::::new() + .for_each(Plan::lessons()) + .join(( + ConstraintFactory::::new().for_each(Plan::teachers()), + equal_bi( + |lesson: &Lesson| lesson.teacher_idx, + |teacher: &Teacher| Some(teacher.index), + ), + )) + .filter(|lesson: &Lesson, teacher: &Teacher| { + lesson.timeslot_idx.is_some_and(|timeslot_idx| { + !teacher + .availability + .get(timeslot_idx) + .copied() + .unwrap_or(false) + }) + }) + .penalize(hard_weight(|_: &Lesson, _: &Teacher| { + HardMediumSoftScore::of_hard(1) + })) + .named("Teacher Availability") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Room, Timeslot, Weekday}; + use chrono::NaiveTime; + use solverforge::ConstraintSet; + + fn time(hour: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, 0, 0).unwrap() + } + + fn score_for_teacher_availability(available: bool, assigned: bool) -> HardMediumSoftScore { + let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8), time(9))]; + let teachers = vec![Teacher::new(0, "Teacher A", vec![available])]; + let rooms = vec![Room::new(0, "Room A")]; + let mut lessons = vec![Lesson::new(0, "Math".to_string(), 0, Some(0), 60)]; + if assigned { + lessons[0].timeslot_idx = Some(0); + lessons[0].room_idx = Some(0); + } + let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); + + (constraint(),).evaluate_all(&plan) + } + + #[test] + fn penalizes_assigned_unavailable_teacher() { + assert_eq!( + score_for_teacher_availability(false, true), + HardMediumSoftScore::of_hard(-1) + ); + } + + #[test] + fn ignores_available_or_unassigned_teacher_slots() { + assert_eq!( + score_for_teacher_availability(true, true), + HardMediumSoftScore::ZERO + ); + assert_eq!( + score_for_teacher_availability(false, false), + HardMediumSoftScore::ZERO + ); + } +} diff --git a/src/data/data_seed.rs b/src/data/data_seed.rs new file mode 100644 index 0000000000000000000000000000000000000000..34da6f40993d56bc921c82e30d06256802dba365 --- /dev/null +++ b/src/data/data_seed.rs @@ -0,0 +1,19 @@ +//! Public demo-data surface for the school timetable example. +//! +//! Keep this file intentionally thin. The rest of the application imports +//! `crate::data::{generate, available_demo_data, DemoData}` as a stable boundary, so +//! the detailed dataset design lives in sibling modules where it can evolve +//! without making the top-level data surface noisy. + +mod entrypoints; +mod groups; +mod large; +mod lessons; +mod rooms; +#[cfg(test)] +mod solve_tests; +mod teachers; +mod timeslots; +mod vocabulary; + +pub use entrypoints::{available_demo_data, default_demo_data, generate, DemoData}; diff --git a/src/data/data_seed/entrypoints.rs b/src/data/data_seed/entrypoints.rs new file mode 100644 index 0000000000000000000000000000000000000000..0d775f6397c4a0df6320ce5718f6b2267253238b --- /dev/null +++ b/src/data/data_seed/entrypoints.rs @@ -0,0 +1,121 @@ +use std::str::FromStr; + +use crate::domain::Plan; + +use super::large::generate_large; + +/// Public demo-data identifiers exposed through the HTTP API. +/// +/// The university app currently ships one serious benchmark instance rather than a +/// menu of toy presets, so the surface stays explicit instead of pretending that +/// multiple sizes exist when they do not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DemoData { + Large, +} + +impl DemoData { + /// Returns the canonical uppercase id used by the HTTP API. + pub fn id(self) -> &'static str { + match self { + DemoData::Large => "LARGE", + } + } + + /// Returns the default demo data. + pub fn default_demo_data() -> Self { + DemoData::Large + } + + /// Returns all available demo data identifiers. + pub fn available_demo_data() -> &'static [Self] { + &[DemoData::Large] + } +} + +/// Returns the default demo data. +pub fn default_demo_data() -> DemoData { + DemoData::Large +} + +/// Returns the list of available demo data identifiers. +pub fn available_demo_data() -> &'static [DemoData] { + DemoData::available_demo_data() +} + +impl FromStr for DemoData { + type Err = (); + + /// Parses the case-insensitive demo id exposed over HTTP. + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "LARGE" => Ok(DemoData::Large), + _ => Err(()), + } + } +} + +/// Generates the requested demo dataset. +/// +/// Dispatch stays here so callers see the supported public variants in one +/// place, while the dataset assembly itself remains hidden in the per-instance +/// modules. +pub fn generate(demo: DemoData) -> Plan { + match demo { + DemoData::Large => generate_large(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constraints::create_constraints; + use solverforge::{ConstraintSet, HardMediumSoftScore}; + + #[test] + fn test_generate_large() { + let plan = generate(DemoData::Large); + assert_eq!(plan.timeslots.len(), 40); + assert_eq!(plan.teachers.len(), 20); + assert_eq!(plan.groups.len(), 12); + assert_eq!(plan.lessons.len(), 300); + assert_eq!(plan.rooms.len(), 10); + assert!(plan + .lessons + .iter() + .all(|lesson| lesson.timeslot_idx.is_none())); + assert!(plan.lessons.iter().all(|lesson| lesson.room_idx.is_none())); + } + + #[test] + fn test_generate_large_initial_score() { + let plan = generate(DemoData::Large); + let score = create_constraints().evaluate_all(&plan); + + assert_eq!(score, HardMediumSoftScore::of_medium(-600)); + } + + #[test] + fn test_demo_data_from_str() { + assert_eq!("LARGE".parse::().ok(), Some(DemoData::Large)); + assert_eq!("large".parse::().ok(), Some(DemoData::Large)); + assert_eq!("INVALID".parse::().ok(), None); + } + + #[test] + fn test_demo_data_id() { + assert_eq!(DemoData::Large.id(), "LARGE"); + } + + #[test] + fn test_default_demo_data() { + assert_eq!(default_demo_data(), DemoData::Large); + assert_eq!(DemoData::default_demo_data(), DemoData::Large); + } + + #[test] + fn test_available_demo_data() { + assert_eq!(available_demo_data(), &[DemoData::Large]); + assert_eq!(DemoData::available_demo_data(), &[DemoData::Large]); + } +} diff --git a/src/data/data_seed/groups.rs b/src/data/data_seed/groups.rs new file mode 100644 index 0000000000000000000000000000000000000000..9aa5591828787ad14d53b563068b5449e907464e --- /dev/null +++ b/src/data/data_seed/groups.rs @@ -0,0 +1,58 @@ +use crate::domain::Group; + +use super::vocabulary::{group_specs, weekly_availability}; + +/// Builds groups with varied availability patterns. +/// +/// Creates groups with different availability to simulate +/// real-world constraints where groups may have restricted schedules. +pub(super) fn build_groups(count: usize) -> Vec { + group_specs() + .iter() + .take(count) + .enumerate() + .map(|(index, spec)| { + let unavailable = [ + index % 8, + 8 + ((index + 2) % 8), + 16 + ((index + 4) % 8), + 24 + ((index + 6) % 8), + 32 + ((index + 1) % 8), + ]; + + Group::new( + index, + spec.name, + spec.student_count, + weekly_availability(&unavailable), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_groups_with_varied_availability() { + let groups = build_groups(12); + assert_eq!(groups.len(), 12); + assert_eq!(groups[0].name, "Cohort 01"); + assert_eq!(groups[5].student_count, 34); + assert_eq!(groups[0].availability.len(), 40); + assert!(!groups[0].availability[0]); + assert!(!groups[0].availability[10]); + assert!(!groups[0].availability[20]); + assert!(!groups[0].availability[30]); + assert!(!groups[0].availability[33]); + assert_eq!( + groups[0] + .availability + .iter() + .filter(|available| !**available) + .count(), + 5 + ); + } +} diff --git a/src/data/data_seed/large.rs b/src/data/data_seed/large.rs new file mode 100644 index 0000000000000000000000000000000000000000..8298c21cb888b533ff2e8b2682981377f7c5a74b --- /dev/null +++ b/src/data/data_seed/large.rs @@ -0,0 +1,43 @@ +use std::sync::OnceLock; + +use crate::domain::Plan; + +use super::groups::build_groups; +use super::lessons::build_lessons; +use super::rooms::build_rooms; +use super::teachers::build_teachers; +use super::timeslots::build_timeslots; +use super::vocabulary::{GROUP_COUNT, ROOM_COUNT, TIMESLOT_COUNT}; + +/// Materializes the canonical university benchmark dataset. +/// +/// We cache the built plan because demo data is immutable and deterministic. +/// Reusing the same constructed instance avoids paying generator cost on every +/// API request while still returning an owned `Plan` to each caller. +pub fn generate_large() -> Plan { + static SCHEDULE: OnceLock = OnceLock::new(); + SCHEDULE.get_or_init(build_large_schedule).clone() +} + +/// Builds the large university timetable instance from scratch. +/// +/// This generates a substantial dataset suitable for benchmarking: +/// - 40 timeslots (full week: Monday-Friday, 8:00-18:00, skipping lunch 12:00-14:00) +/// - 20 teachers with subject-specific availability +/// - 12 groups +/// - 300 lessons (25 per group based on subject hours allocation) +/// - 10 typed rooms +fn build_large_schedule() -> Plan { + // Full week timeslots: 5 days * 8 slots per day = 40 timeslots + let timeslots = build_timeslots(TIMESLOT_COUNT); + + let teachers = build_teachers(); + + let groups = build_groups(GROUP_COUNT); + + let lessons = build_lessons(GROUP_COUNT); + + let rooms = build_rooms(ROOM_COUNT); + + Plan::new(timeslots, teachers, groups, lessons, rooms) +} diff --git a/src/data/data_seed/lessons.rs b/src/data/data_seed/lessons.rs new file mode 100644 index 0000000000000000000000000000000000000000..e59dafa2cf3706b01de6ae4df31aa10f3d655238 --- /dev/null +++ b/src/data/data_seed/lessons.rs @@ -0,0 +1,172 @@ +use crate::domain::Lesson; + +use super::vocabulary::{group_specs, subjects, teacher_index, LESSON_DURATION_MINUTES}; + +/// Builds lessons for all groups based on subject configuration. +/// +/// For each group, creates lessons for all subjects with their allocated hours_per_week. +/// Each lesson is assigned to a group, a teacher, and a required room kind. +/// +/// Teacher assignment: for a given subject, group 0 gets teachers[0], group 1 gets teachers[1], +/// etc. (wrapping around if there are more groups than teachers for that subject). +/// +/// Total lessons = GROUP_COUNT * sum(hours_per_week for all subjects). +/// With 12 groups and current config: 12 * 25 = 300 lessons. +pub(super) fn build_lessons(group_count: usize) -> Vec { + let mut lessons = Vec::new(); + let mut lesson_index = 0; + + // For each group + for group_idx in 0..group_count { + let student_count = group_specs()[group_idx].student_count; + + // For each subject, create hours_per_week lessons + for subject_config in subjects() { + // Determine which teacher to use for this subject and group + // Use round-robin: group_idx % number_of_teachers_for_subject + for lesson_num in 0..subject_config.hours_per_week { + let teacher_name = subject_config.teachers + [(group_idx + lesson_num) % subject_config.teachers.len()]; + let teacher_idx = teacher_index(teacher_name); + + lessons.push(Lesson::with_details( + lesson_index, + subject_config.name.to_string(), + group_idx, + student_count, + Some(teacher_idx), + LESSON_DURATION_MINUTES, + subject_config.room_kind, + )); + lesson_index += 1; + } + } + } + + lessons +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_lessons() { + let lessons = build_lessons(12); + assert_eq!(lessons.len(), 300); + } + + #[test] + fn test_all_groups_have_all_subjects() { + let lessons = build_lessons(12); + let subject_names: Vec<&str> = subjects().iter().map(|subject| subject.name).collect(); + + // Check each group has lessons for all subjects + for group_idx in 0..12 { + let group_lessons: Vec<_> = lessons + .iter() + .filter(|l| l.group_idx == group_idx) + .collect(); + + for subject_name in &subject_names { + let subject_lessons: Vec<_> = group_lessons + .iter() + .filter(|l| l.subject == *subject_name) + .collect(); + + let expected_count = subjects() + .iter() + .find(|subject| subject.name == *subject_name) + .unwrap() + .hours_per_week; + assert_eq!( + subject_lessons.len(), + expected_count, + "Group {} should have {} lessons for {}", + group_idx, + expected_count, + subject_name + ); + } + } + } + + #[test] + fn test_teacher_assignment() { + let lessons = build_lessons(12); + + // The generated catalog has four English teachers, assigned round-robin. + let english_lessons: Vec<_> = lessons.iter().filter(|l| l.subject == "English").collect(); + + assert_eq!(english_lessons.len(), 48); + + // Check teacher assignment pattern for English + for group_idx in 0..12 { + let group_english: Vec<_> = english_lessons + .iter() + .filter(|l| l.group_idx == group_idx) + .collect(); + + let expected_teacher_idx = teacher_index( + [ + "Jane Austen", + "William Shakespeare", + "Chinua Achebe", + "Mary Shelley", + ][group_idx % 4], + ); + + assert_eq!(group_english[0].teacher_idx, Some(expected_teacher_idx)); + } + } + + #[test] + fn test_lesson_duration() { + let lessons = build_lessons(12); + for lesson in &lessons { + assert_eq!(lesson.duration, LESSON_DURATION_MINUTES); + } + } + + #[test] + fn test_subjects_are_classic_uk_school() { + let subject_names: Vec<&str> = subjects().iter().map(|subject| subject.name).collect(); + + assert!(subject_names.contains(&"Mathematics")); + assert!(subject_names.contains(&"Physics")); + assert!(subject_names.contains(&"Chemistry")); + assert!(subject_names.contains(&"Biology")); + assert!(subject_names.contains(&"Computer Science")); + assert!(subject_names.contains(&"English")); + assert!(subject_names.contains(&"History")); + assert!(subject_names.contains(&"Geography")); + assert!(subject_names.contains(&"French")); + assert!(subject_names.contains(&"German")); + assert_eq!(subject_names.len(), 10); + } + + #[test] + fn test_total_lessons_per_group() { + let lessons = build_lessons(12); + // Each group should have 25 lessons (sum of hours_per_week) + for group_idx in 0..12 { + let group_lessons: Vec<_> = lessons + .iter() + .filter(|l| l.group_idx == group_idx) + .collect(); + assert_eq!(group_lessons.len(), 25); + } + } + + #[test] + fn test_all_lessons_have_teacher() { + let lessons = build_lessons(12); + for lesson in &lessons { + assert!( + lesson.teacher_idx.is_some(), + "Lesson {} has no teacher", + lesson.id + ); + } + } +} diff --git a/src/data/data_seed/rooms.rs b/src/data/data_seed/rooms.rs new file mode 100644 index 0000000000000000000000000000000000000000..e9c5bcf3b12869ad1dfe92d116f967ea9d982deb --- /dev/null +++ b/src/data/data_seed/rooms.rs @@ -0,0 +1,26 @@ +use crate::domain::Room; + +use super::vocabulary::room_specs; + +pub(super) fn build_rooms(count: usize) -> Vec { + room_specs() + .iter() + .take(count) + .enumerate() + .map(|(index, spec)| Room::with_kind_capacity(index, spec.name, spec.kind, spec.capacity)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_rooms() { + let rooms = build_rooms(10); + assert_eq!(rooms.len(), 10); + assert_eq!(rooms[0].id, "room-0"); + assert_eq!(rooms[0].name, "Auditorium A"); + assert_eq!(rooms[7].name, "Computer Lab"); + } +} diff --git a/src/data/data_seed/solve_tests.rs b/src/data/data_seed/solve_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..46d40942c2394fbd04d9d80943c06e9005ea8c63 --- /dev/null +++ b/src/data/data_seed/solve_tests.rs @@ -0,0 +1,152 @@ +use solverforge::{ConstraintSet, SolverEvent, SolverManager}; +use std::collections::BTreeSet; + +use super::{generate, DemoData}; +use crate::domain::Plan; + +// Static manager — must be 'static for retained job execution. +static MANAGER: SolverManager = SolverManager::new(); + +fn schedule() -> Plan { + generate(DemoData::Large) +} + +/// Slow end-to-end acceptance test for the large dataset. +/// +/// This verifies that the solver starts from an unassigned schedule, reaches a +/// hard/medium-feasible timetable, and keeps a visible soft optimization score. +#[test] +#[ignore = "slow acceptance test for the large dataset"] +fn large_demo_solves_to_feasible_progressing_schedule() { + let plan = schedule(); + let initial_score = crate::constraints::create_constraints().evaluate_all(&plan); + assert_eq!( + initial_score, + solverforge::HardMediumSoftScore::of_medium(-600), + "The generated demo must start unassigned, not already solved" + ); + + let (job_id, mut receiver) = MANAGER.solve(plan).expect("job should start"); + let mut completed_score = None; + let mut completed_solution = None; + let mut observed_scores = Vec::new(); + + while let Some(event) = receiver.blocking_recv() { + if let Some(score) = event.metadata().current_score { + observed_scores.push(score); + } + + match event { + SolverEvent::Completed { solution, .. } => { + completed_score = solution.score; + completed_solution = Some(solution); + break; + } + SolverEvent::Failed { error, .. } => { + panic!("demo solve failed unexpectedly: {error}"); + } + _ => {} + } + } + + let score = completed_score.expect("expected a completed score"); + let solution = completed_solution.expect("expected a completed solution"); + + // The best solution must satisfy both the hard feasibility rules and the + // medium-level assignment requirements while retaining soft optimization + // pressure that lets the UI show continued score movement. + assert_eq!( + score.hard(), + 0, + "Expected hard-feasible solution, but got: {}", + score + ); + assert_eq!( + score.medium(), + 0, + "Expected all lessons assigned, but got: {}", + score + ); + assert!( + score.soft() < 0, + "Expected remaining soft penalties for realistic timetable quality, got: {}", + score + ); + assert!( + score.medium() > initial_score.medium(), + "Expected terminal score to improve from the unassigned medium penalty" + ); + assert!( + observed_scores.contains(&initial_score), + "Expected event stream to expose the unassigned initial score" + ); + assert!( + observed_scores + .iter() + .any(|score| score.hard() == 0 && score.medium() == 0 && score.soft() < 0), + "Expected event stream to expose a feasible soft-scored timetable" + ); + + let lesson_count = solution.lessons.len(); + let assigned_timeslots = solution + .lessons + .iter() + .filter(|l| l.timeslot_idx.is_some()) + .count(); + let assigned_rooms = solution + .lessons + .iter() + .filter(|l| l.room_idx.is_some()) + .count(); + + assert_eq!( + assigned_timeslots, lesson_count, + "Every lesson must have a timeslot assignment" + ); + assert_eq!( + assigned_rooms, lesson_count, + "Every lesson must have a room assignment" + ); + + let distinct_timeslots = solution + .lessons + .iter() + .filter_map(|lesson| lesson.timeslot_idx) + .collect::>() + .len(); + let distinct_rooms = solution + .lessons + .iter() + .filter_map(|lesson| lesson.room_idx) + .collect::>() + .len(); + + assert!( + distinct_timeslots > 1, + "The solved timetable must not collapse every lesson into one timeslot" + ); + assert!( + distinct_rooms > 1, + "The solved timetable must not collapse every lesson into one room" + ); + + let constraints = crate::constraints::create_constraints(); + let hard_or_medium_constraints: Vec<_> = constraints + .evaluate_detailed(&solution) + .into_iter() + .filter(|analysis| analysis.score.hard() != 0 || analysis.score.medium() != 0) + .map(|analysis| format!("{}={}", analysis.constraint_ref.name, analysis.score)) + .collect(); + assert!( + hard_or_medium_constraints.is_empty(), + "Expected all hard/medium constraints to score zero, got: {}", + hard_or_medium_constraints.join(", ") + ); + + eprintln!( + "Solution: {} lessons, {} timeslots assigned, {} rooms assigned, score {}", + lesson_count, assigned_timeslots, assigned_rooms, score + ); + + MANAGER.delete(job_id).expect("delete completed job"); +} diff --git a/src/data/data_seed/teachers.rs b/src/data/data_seed/teachers.rs new file mode 100644 index 0000000000000000000000000000000000000000..06e022b3fb7bbbd6cc0eba5b7b8206d5a10cd4b5 --- /dev/null +++ b/src/data/data_seed/teachers.rs @@ -0,0 +1,67 @@ +use crate::domain::Teacher; + +use super::vocabulary::{teacher_names, weekly_availability}; + +/// Builds teachers with varied availability patterns. +/// Each teacher is created from the teacher_names() list with their full name. +pub(super) fn build_teachers() -> Vec { + teacher_names() + .iter() + .enumerate() + .map(|(index, name)| { + let unavailable = [ + index % 8, + 8 + ((index * 3) % 8), + 16 + ((index * 5) % 8), + 32 + ((index * 7) % 8), + ]; + + Teacher::new(index, name.to_string(), weekly_availability(&unavailable)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_teachers() { + let teachers = build_teachers(); + let teacher_names_list = teacher_names(); + assert_eq!(teachers.len(), teacher_names_list.len()); + + for (index, teacher) in teachers.iter().enumerate() { + assert_eq!(teacher.availability.len(), 40); + assert!(!teacher.availability[index % 8]); + assert_eq!( + teacher + .availability + .iter() + .filter(|available| !**available) + .count(), + 4 + ); + } + } + + #[test] + fn test_teacher_names() { + let teachers = build_teachers(); + assert_eq!(teachers.len(), 20); + assert_eq!(teachers[0].name, "Jane Austen"); + assert_eq!(teachers[1].name, "William Shakespeare"); + assert_eq!(teachers[15].name, "Ada Lovelace"); + assert_eq!(teachers[16].name, "Alan Turing"); + assert_eq!(teachers[19].name, "Alexander von Humboldt"); + } + + #[test] + fn test_teacher_names_from_subjects() { + let names = teacher_names(); + assert_eq!(names.len(), 20); + assert!(names.contains(&"Marie Curie")); + assert!(names.contains(&"Alan Turing")); + assert!(names.contains(&"William Shakespeare")); + } +} diff --git a/src/data/data_seed/timeslots.rs b/src/data/data_seed/timeslots.rs new file mode 100644 index 0000000000000000000000000000000000000000..4233a8280ae671731b32517c1ea40ba9c98331d0 --- /dev/null +++ b/src/data/data_seed/timeslots.rs @@ -0,0 +1,208 @@ +use chrono::NaiveTime; + +use crate::domain::{Timeslot, Weekday}; + +use super::vocabulary::{ + DAY_END_HOUR, DAY_START_HOUR, LESSON_DURATION_HOURS, LUNCH_BREAK_END, LUNCH_BREAK_START, +}; + +/// Builds a deterministic set of timeslots for the schedule. +/// +/// Creates 1-hour timeslots for each day of the week (Monday-Friday): +/// - 8:00-9:00 +/// - 9:00-10:00 +/// - 10:00-11:00 +/// - 11:00-12:00 +/// - 14:00-15:00 (lunch break 12:00-14:00 skipped) +/// - 15:00-16:00 +/// - 16:00-17:00 +/// - 17:00-18:00 +/// +/// Total: 40 timeslots (5 days * 8 slots per day) +pub(super) fn build_timeslots(count: usize) -> Vec { + // If count is 0 or very small, use the simple sequential approach + if count <= 7 { + return build_simple_timeslots(count); + } + + // Build 1-hour timeslots for a full week, skipping lunch break (12:00-14:00) + let mut timeslots = Vec::with_capacity(count.min(40)); + let mut index = 0; + + for day in [ + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + ] { + if index >= count { + break; + } + + // Generate hour slots from 8:00 to 18:00, skipping 12:00-14:00 + for hour in DAY_START_HOUR..DAY_END_HOUR { + if index >= count { + break; + } + + // Skip lunch break: no timeslots from 12:00 to 14:00 + if (LUNCH_BREAK_START..LUNCH_BREAK_END).contains(&hour) { + continue; + } + + let start_hour = hour; + let end_hour = start_hour + LESSON_DURATION_HOURS; + + timeslots.push(Timeslot::new( + index, + day, + NaiveTime::from_hms_opt(start_hour, 0, 0).unwrap(), + NaiveTime::from_hms_opt(end_hour, 0, 0).unwrap(), + )); + index += 1; + } + } + + timeslots +} + +/// Builds simple sequential timeslots with default values. +/// Used for small test cases or when count is very small. +pub(super) fn build_simple_timeslots(count: usize) -> Vec { + (0..count) + .map(|index| Timeslot { + id: format!("timeslot-{index}"), + index, + day_of_week: Default::default(), + start_time: Default::default(), + end_time: Default::default(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use chrono::Timelike; + + use super::*; + + #[test] + fn test_build_simple_timeslots() { + let timeslots = build_simple_timeslots(3); + assert_eq!(timeslots.len(), 3); + assert_eq!(timeslots[0].id, "timeslot-0"); + assert_eq!(timeslots[1].id, "timeslot-1"); + assert_eq!(timeslots[2].id, "timeslot-2"); + } + + #[test] + fn test_build_timeslots_full_week() { + let timeslots = build_timeslots(40); + assert_eq!(timeslots.len(), 40); + + // Check first timeslot is Monday 8:00-9:00 + assert_eq!(timeslots[0].index, 0); + assert_eq!(timeslots[0].day_of_week, Weekday::Mon); + assert_eq!( + timeslots[0].start_time, + NaiveTime::from_hms_opt(8, 0, 0).unwrap() + ); + assert_eq!( + timeslots[0].end_time, + NaiveTime::from_hms_opt(9, 0, 0).unwrap() + ); + + // Check Monday morning slots (8:00-9:00, 9:00-10:00, 10:00-11:00, 11:00-12:00) + assert_eq!( + timeslots[0].start_time, + NaiveTime::from_hms_opt(8, 0, 0).unwrap() + ); + assert_eq!( + timeslots[1].start_time, + NaiveTime::from_hms_opt(9, 0, 0).unwrap() + ); + assert_eq!( + timeslots[2].start_time, + NaiveTime::from_hms_opt(10, 0, 0).unwrap() + ); + assert_eq!( + timeslots[3].start_time, + NaiveTime::from_hms_opt(11, 0, 0).unwrap() + ); + + // Check that 12:00-13:00 and 13:00-14:00 are skipped (no slot starts at 12 or 13) + // After 11:00-12:00 (index 3), next should be 14:00-15:00 + assert_eq!( + timeslots[4].start_time, + NaiveTime::from_hms_opt(14, 0, 0).unwrap() + ); + assert_eq!( + timeslots[4].end_time, + NaiveTime::from_hms_opt(15, 0, 0).unwrap() + ); + + // Check Monday afternoon slots (14:00-15:00, 15:00-16:00, 16:00-17:00, 17:00-18:00) + assert_eq!( + timeslots[4].start_time, + NaiveTime::from_hms_opt(14, 0, 0).unwrap() + ); + assert_eq!( + timeslots[5].start_time, + NaiveTime::from_hms_opt(15, 0, 0).unwrap() + ); + assert_eq!( + timeslots[6].start_time, + NaiveTime::from_hms_opt(16, 0, 0).unwrap() + ); + assert_eq!( + timeslots[7].start_time, + NaiveTime::from_hms_opt(17, 0, 0).unwrap() + ); + + // Check first timeslot of Tuesday (index 8) + assert_eq!(timeslots[8].index, 8); + assert_eq!(timeslots[8].day_of_week, Weekday::Tue); + assert_eq!( + timeslots[8].start_time, + NaiveTime::from_hms_opt(8, 0, 0).unwrap() + ); + } + + #[test] + fn test_build_timeslots_partial() { + let timeslots = build_timeslots(10); + assert_eq!(timeslots.len(), 10); + // Should have Monday (8 slots) + first 2 of Tuesday = 10 timeslots + assert_eq!(timeslots[0].day_of_week, Weekday::Mon); + assert_eq!(timeslots[8].day_of_week, Weekday::Tue); + assert_eq!( + timeslots[8].start_time, + NaiveTime::from_hms_opt(8, 0, 0).unwrap() + ); + assert_eq!( + timeslots[9].start_time, + NaiveTime::from_hms_opt(9, 0, 0).unwrap() + ); + } + + #[test] + fn test_no_lunch_break_slots() { + let timeslots = build_timeslots(40); + // Verify no timeslot starts at 12:00 or 13:00 + for timeslot in ×lots { + let start_hour = timeslot.start_time.hour(); + assert_ne!(start_hour, 12, "No timeslot should start at 12:00"); + assert_ne!(start_hour, 13, "No timeslot should start at 13:00"); + } + } + + #[test] + fn test_all_slots_are_one_hour() { + let timeslots = build_timeslots(40); + for timeslot in ×lots { + let duration = timeslot.end_time.signed_duration_since(timeslot.start_time); + assert_eq!(duration.num_hours(), 1, "All timeslots should be 1 hour"); + } + } +} diff --git a/src/data/data_seed/vocabulary.rs b/src/data/data_seed/vocabulary.rs new file mode 100644 index 0000000000000000000000000000000000000000..80e19f89c18a766dda7a425cc1f24393caf57031 --- /dev/null +++ b/src/data/data_seed/vocabulary.rs @@ -0,0 +1,280 @@ +//! Generator constants and shared university vocabulary. + +use crate::domain::RoomKind; + +// Timeslot configuration +pub(super) const LESSON_DURATION_HOURS: u32 = 1; +pub(super) const DAY_START_HOUR: u32 = 8; +pub(super) const DAY_END_HOUR: u32 = 18; +pub(super) const LUNCH_BREAK_START: u32 = 12; +pub(super) const LUNCH_BREAK_END: u32 = 14; + +// Large dataset sizes +pub(super) const TIMESLOT_COUNT: usize = 40; +pub(super) const GROUP_COUNT: usize = 12; +pub(super) const ROOM_COUNT: usize = 10; + +// Lesson configuration +pub(super) const LESSON_DURATION_MINUTES: u32 = 60; + +/// Subject configuration with weekly demand, qualified teachers, and room type. +#[derive(Debug, Clone, Copy)] +pub(super) struct Subject { + pub name: &'static str, + pub hours_per_week: usize, + pub teachers: &'static [&'static str], + pub room_kind: RoomKind, +} + +/// Fixed room inventory used by the canonical benchmark instance. +#[derive(Debug, Clone, Copy)] +pub(super) struct RoomSpec { + pub name: &'static str, + pub kind: RoomKind, + pub capacity: usize, +} + +/// Fixed cohort inventory used by the canonical benchmark instance. +#[derive(Debug, Clone, Copy)] +pub(super) struct GroupSpec { + pub name: &'static str, + pub student_count: usize, +} + +/// Ordered subject catalog. +/// +/// The weekly load is 25 lessons per cohort. With 12 cohorts this produces 300 +/// unassigned lessons for the solver to place. +pub(super) fn subjects() -> &'static [Subject] { + &[ + Subject { + name: "English", + hours_per_week: 4, + teachers: &[ + "Jane Austen", + "William Shakespeare", + "Chinua Achebe", + "Mary Shelley", + ], + room_kind: RoomKind::Lecture, + }, + Subject { + name: "Mathematics", + hours_per_week: 4, + teachers: &[ + "Isaac Newton", + "Emmy Noether", + "Katherine Johnson", + "Florence Nightingale", + ], + room_kind: RoomKind::Lecture, + }, + Subject { + name: "Physics", + hours_per_week: 3, + teachers: &["Marie Curie", "Albert Einstein", "Stephen Hawking"], + room_kind: RoomKind::Lab, + }, + Subject { + name: "Chemistry", + hours_per_week: 3, + teachers: &["Marie Curie", "Albert Einstein", "Rosalind Franklin"], + room_kind: RoomKind::Lab, + }, + Subject { + name: "Biology", + hours_per_week: 3, + teachers: &[ + "Rosalind Franklin", + "Charles Darwin", + "Jane Goodall", + "Rachel Carson", + ], + room_kind: RoomKind::Lab, + }, + Subject { + name: "Computer Science", + hours_per_week: 2, + teachers: &[ + "Ada Lovelace", + "Alan Turing", + "Grace Hopper", + "Donald Knuth", + ], + room_kind: RoomKind::Computer, + }, + Subject { + name: "History", + hours_per_week: 2, + teachers: &[ + "Jane Austen", + "William Shakespeare", + "Chinua Achebe", + "Mary Shelley", + ], + room_kind: RoomKind::Lecture, + }, + Subject { + name: "Geography", + hours_per_week: 2, + teachers: &[ + "Charles Darwin", + "Jane Goodall", + "Rachel Carson", + "Alexander von Humboldt", + ], + room_kind: RoomKind::Lecture, + }, + Subject { + name: "French", + hours_per_week: 1, + teachers: &["Chinua Achebe", "Mary Shelley"], + room_kind: RoomKind::Language, + }, + Subject { + name: "German", + hours_per_week: 1, + teachers: &["William Shakespeare", "Alexander von Humboldt"], + room_kind: RoomKind::Language, + }, + ] +} + +/// Returns teacher names in first-use catalog order. +pub(super) fn teacher_names() -> Vec<&'static str> { + let mut names = Vec::new(); + for subject in subjects() { + for teacher in subject.teachers { + if !names.contains(teacher) { + names.push(*teacher); + } + } + } + names +} + +/// Returns the index of a teacher name in the generated teacher list. +pub(super) fn teacher_index(name: &str) -> usize { + teacher_names().iter().position(|&n| n == name).unwrap() +} + +/// Returns the rooms available in the generated instance. +pub(super) fn room_specs() -> &'static [RoomSpec] { + &[ + RoomSpec { + name: "Auditorium A", + kind: RoomKind::Lecture, + capacity: 120, + }, + RoomSpec { + name: "Auditorium B", + kind: RoomKind::Lecture, + capacity: 80, + }, + RoomSpec { + name: "Seminar 1", + kind: RoomKind::Lecture, + capacity: 40, + }, + RoomSpec { + name: "Seminar 2", + kind: RoomKind::Lecture, + capacity: 36, + }, + RoomSpec { + name: "Wet Lab 1", + kind: RoomKind::Lab, + capacity: 36, + }, + RoomSpec { + name: "Wet Lab 2", + kind: RoomKind::Lab, + capacity: 36, + }, + RoomSpec { + name: "Wet Lab 3", + kind: RoomKind::Lab, + capacity: 36, + }, + RoomSpec { + name: "Computer Lab", + kind: RoomKind::Computer, + capacity: 36, + }, + RoomSpec { + name: "Language Room A", + kind: RoomKind::Language, + capacity: 36, + }, + RoomSpec { + name: "Language Room B", + kind: RoomKind::Language, + capacity: 36, + }, + ] +} + +/// Returns the cohorts available in the generated instance. +pub(super) fn group_specs() -> &'static [GroupSpec] { + &[ + GroupSpec { + name: "Cohort 01", + student_count: 24, + }, + GroupSpec { + name: "Cohort 02", + student_count: 26, + }, + GroupSpec { + name: "Cohort 03", + student_count: 28, + }, + GroupSpec { + name: "Cohort 04", + student_count: 30, + }, + GroupSpec { + name: "Cohort 05", + student_count: 32, + }, + GroupSpec { + name: "Cohort 06", + student_count: 34, + }, + GroupSpec { + name: "Cohort 07", + student_count: 22, + }, + GroupSpec { + name: "Cohort 08", + student_count: 25, + }, + GroupSpec { + name: "Cohort 09", + student_count: 29, + }, + GroupSpec { + name: "Cohort 10", + student_count: 31, + }, + GroupSpec { + name: "Cohort 11", + student_count: 33, + }, + GroupSpec { + name: "Cohort 12", + student_count: 27, + }, + ] +} + +/// Builds a 40-slot weekly availability vector with selected unavailable slots. +pub(super) fn weekly_availability(unavailable_slots: &[usize]) -> Vec { + let mut availability = vec![true; TIMESLOT_COUNT]; + for slot in unavailable_slots { + if let Some(value) = availability.get_mut(*slot) { + *value = false; + } + } + availability +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..805bbc47a7e4d6768e90cd52c36f9c12871f9ad1 --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,8 @@ +//! Stable demo-data boundary for the lesson-timetabling app. +//! +//! Other layers import from `crate::data` instead of seed-specific files. That +//! keeps demo-id parsing and timetable generation behind one small interface. + +mod data_seed; + +pub use data_seed::{available_demo_data, default_demo_data, generate, DemoData}; diff --git a/src/domain/group.rs b/src/domain/group.rs new file mode 100644 index 0000000000000000000000000000000000000000..18c91477c2c3f24ddfe844f99e17eaaa32e59d7f --- /dev/null +++ b/src/domain/group.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +/// A student cohort that receives a weekly timetable. +#[problem_fact] +#[derive(Serialize, Deserialize)] +pub struct Group { + #[planning_id] + pub id: String, + #[serde(skip)] + pub index: usize, // the solver-facing join key + pub name: String, + pub student_count: usize, + pub availability: Vec, +} + +impl Group { + pub fn new( + index: usize, + name: impl Into, + student_count: usize, + availability: impl Into>, + ) -> Self { + Self { + id: format!("group-{index}"), + index, + name: name.into(), + student_count, + availability: availability.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_group_construction() { + let fact = Group::new(0, "test", 32, vec![true; 40]); + assert_eq!(fact.index, 0); + assert_eq!(fact.id, "group-0"); + assert_eq!(fact.name, "test"); + assert_eq!(fact.student_count, 32); + let _ = &fact.id; + let _ = &fact.availability; + } +} diff --git a/src/domain/lesson.rs b/src/domain/lesson.rs new file mode 100644 index 0000000000000000000000000000000000000000..fff2bff1be7d8c5ad69edb9afcf227eb646f51e9 --- /dev/null +++ b/src/domain/lesson.rs @@ -0,0 +1,118 @@ +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +use super::RoomKind; + +/// A subject meeting that the solver assigns to one timeslot and one room. +#[planning_entity] +#[derive(Serialize, Deserialize)] +pub struct Lesson { + #[planning_id] + pub id: String, + /// Dense solver-facing join key rebuilt by `Plan::rebuild_derived_fields`. + #[serde(skip)] + pub index: usize, + pub subject: String, + pub group_idx: usize, + pub student_count: usize, + pub teacher_idx: Option, + pub duration: u32, + pub required_room_kind: RoomKind, + // @solverforge:begin entity-variables + /// Scalar planning variable pointing at `Plan.timeslots`. + #[planning_variable(value_range_provider = "timeslots", allows_unassigned = false)] + pub timeslot_idx: Option, + /// Scalar planning variable pointing at `Plan.rooms`. + #[planning_variable(value_range_provider = "rooms", allows_unassigned = false)] + pub room_idx: Option, + // @solverforge:end entity-variables +} + +impl Lesson { + /// Builds an unassigned lesson with the default lecture-room requirement. + pub fn new( + index: usize, + subject: String, + group_idx: usize, + teacher_idx: Option, + duration: u32, + ) -> Self { + Self::with_required_room_kind( + index, + subject, + group_idx, + teacher_idx, + duration, + RoomKind::Lecture, + ) + } + + /// Builds an unassigned lesson with an explicit room-kind requirement. + pub fn with_required_room_kind( + index: usize, + subject: String, + group_idx: usize, + teacher_idx: Option, + duration: u32, + required_room_kind: RoomKind, + ) -> Self { + Self::with_details( + index, + subject, + group_idx, + 30, + teacher_idx, + duration, + required_room_kind, + ) + } + + /// Builds an unassigned lesson with every field needed by the data seed. + pub fn with_details( + index: usize, + subject: String, + group_idx: usize, + student_count: usize, + teacher_idx: Option, + duration: u32, + required_room_kind: RoomKind, + ) -> Self { + Self { + id: format!("lesson-{index}"), + index, + subject, + group_idx, + student_count, + teacher_idx, + duration, + required_room_kind, + // @solverforge:begin entity-variable-init + timeslot_idx: None, + room_idx: None, + // @solverforge:end entity-variable-init + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lesson_construction() { + let entity = Lesson::new( + 0, + "test".to_string(), + Default::default(), + None, + Default::default(), + ); + assert_eq!(entity.id, "lesson-0"); + let _ = &entity.subject; + let _ = &entity.group_idx; + let _ = &entity.student_count; + let _ = &entity.teacher_idx; + let _ = &entity.duration; + let _ = &entity.required_room_kind; + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..497433bf3b76e045c71ba3d19764f6bccf214741 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,28 @@ +//! Planning-model manifest and domain-layer exports. +//! +//! `planning_model!` is the SolverForge boundary for this app. Keep the exports +//! in the same conceptual order as `solverforge.app.toml`: facts, planning +//! entity, then solution. + +solverforge::planning_model! { + root = "src/domain"; + + mod weekday; + pub use weekday::Weekday; + + // @solverforge:begin domain-exports + mod timeslot; + mod teacher; + mod group; + mod lesson; + mod room; + mod plan; + + pub use timeslot::Timeslot; + pub use teacher::Teacher; + pub use group::Group; + pub use lesson::Lesson; + pub use room::{Room, RoomKind}; + pub use plan::Plan; + // @solverforge:end domain-exports +} diff --git a/src/domain/plan.rs b/src/domain/plan.rs new file mode 100644 index 0000000000000000000000000000000000000000..fbd17c118c733068021f36e04e2c2e1181697242 --- /dev/null +++ b/src/domain/plan.rs @@ -0,0 +1,249 @@ +//! Planning solution for the lesson timetabling problem. +//! +//! `Plan` is both the input to SolverForge and the domain value converted to +//! JSON snapshots after solving. Facts stay read-only; lessons carry the +//! mutable timeslot and room choices. + +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +// @solverforge:begin solution-imports +use super::Group; +use super::Lesson; +use super::Room; +use super::Teacher; +use super::Timeslot; +// @solverforge:end solution-imports + +/// Full planning solution passed to the SolverForge runtime and HTTP API. +#[planning_solution( + constraints = "crate::constraints::create_constraints", + solver_toml = "../../solver.toml" +)] +#[derive(Serialize, Deserialize)] +pub struct Plan { + // @solverforge:begin solution-collections + /// Weekly slots a lesson can occupy. + #[problem_fact_collection] + pub timeslots: Vec, + /// Teachers and their availability calendars. + #[problem_fact_collection] + pub teachers: Vec, + /// Student cohorts that need complete timetables. + #[problem_fact_collection] + pub groups: Vec, + /// Lesson entities whose timeslot and room variables are changed by search. + #[planning_entity_collection] + pub lessons: Vec, + /// Candidate teaching spaces. + #[problem_fact_collection] + pub rooms: Vec, + // @solverforge:end solution-collections + #[planning_score] + pub score: Option, +} + +impl Plan { + /// Builds a normalized timetable plan from facts and lesson entities. + #[rustfmt::skip] + pub fn new( + // @solverforge:begin solution-constructor-params + timeslots: Vec, + teachers: Vec, + groups: Vec, + lessons: Vec, + rooms: Vec, + // @solverforge:end solution-constructor-params + ) -> Self { + let mut schedule: Plan = Self{ + // @solverforge:begin solution-constructor-init + timeslots, + teachers, + groups, + lessons, + rooms, + // @solverforge:end solution-constructor-init + score: None, + }; + schedule.rebuild_derived_fields(); + schedule + } + + /// Recomputes indexes for entity join keys. + /// + /// This runs after generation and after transport decoding so the domain + /// model always reaches the solver in a normalized state. + /// + /// Sets the `index` field on facts and lessons to match their position in + /// their respective collections. These indexes are used as solver-facing + /// join keys for constraint streams (e.g., `lesson.timeslot_idx` joins with + /// `timeslot.index`, while `lesson.index` separates lesson pairs). + pub fn rebuild_derived_fields(&mut self) { + for (index, timeslot) in self.timeslots.iter_mut().enumerate() { + timeslot.index = index; + } + for (index, teacher) in self.teachers.iter_mut().enumerate() { + teacher.index = index; + } + for (index, group) in self.groups.iter_mut().enumerate() { + group.index = index; + } + for (index, room) in self.rooms.iter_mut().enumerate() { + room.index = index; + } + + // Planning variables are optional indexes. When a stale browser payload + // sends an out-of-range index, clear it so SolverForge sees an + // unassigned variable instead of indexing past the candidate list. + for (index, lesson) in self.lessons.iter_mut().enumerate() { + lesson.index = index; + lesson.timeslot_idx = lesson + .timeslot_idx + .filter(|idx| *idx < self.timeslots.len()); + lesson.room_idx = lesson.room_idx.filter(|idx| *idx < self.rooms.len()); + } + } + + /// Safe index lookup used by constraints and diagnostics. + #[inline] + pub fn get_timeslot(&self, idx: usize) -> Option<&Timeslot> { + self.timeslots.get(idx) + } + + /// Safe index lookup used by constraints and diagnostics. + #[inline] + pub fn get_teacher(&self, idx: usize) -> Option<&Teacher> { + self.teachers.get(idx) + } + + /// Safe index lookup used by constraints and diagnostics. + #[inline] + pub fn get_group(&self, idx: usize) -> Option<&Group> { + self.groups.get(idx) + } + + /// Safe index lookup used by constraints and diagnostics. + #[inline] + pub fn get_room(&self, idx: usize) -> Option<&Room> { + self.rooms.get(idx) + } + + /// Named slice accessor used by joins and SolverForge transport code. + #[inline] + pub fn timeslots_slice(&self) -> &[Timeslot] { + self.timeslots.as_slice() + } + + /// Named slice accessor used by joins and SolverForge transport code. + #[inline] + pub fn teachers_slice(&self) -> &[Teacher] { + self.teachers.as_slice() + } + + /// Named slice accessor used by joins and SolverForge transport code. + #[inline] + pub fn groups_slice(&self) -> &[Group] { + self.groups.as_slice() + } + + /// Named slice accessor used by joins and SolverForge transport code. + #[inline] + pub fn rooms_slice(&self) -> &[Room] { + self.rooms.as_slice() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::Weekday; + + #[test] + fn test_rebuild_derived_fields_filters_out_of_bounds_indices() { + use chrono::NaiveTime; + + // Create a plan with 2 timeslots and 2 rooms + let timeslots = vec![ + Timeslot::new(0, Weekday::Mon, NaiveTime::from_hms_opt(8, 0, 0).unwrap(), NaiveTime::from_hms_opt(10, 0, 0).unwrap()), + Timeslot::new(1, Weekday::Mon, NaiveTime::from_hms_opt(10, 0, 0).unwrap(), NaiveTime::from_hms_opt(12, 0, 0).unwrap()), + ]; + let teachers = vec![]; + let groups = vec![]; + let rooms = vec![ + Room::new(0, "Room A"), + Room::new(1, "Room B"), + ]; + let lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 0, None, 120), + ]; + + let mut plan = Plan::new(timeslots, teachers, groups, lessons, rooms); + + // Manually corrupt the indices to simulate deserialization from invalid data + plan.lessons[0].timeslot_idx = Some(100); // Out of bounds + plan.lessons[0].room_idx = Some(100); // Out of bounds + plan.lessons[1].timeslot_idx = Some(1); // Valid + plan.lessons[1].room_idx = Some(1); // Valid + + // Rebuild should filter out the invalid indices + plan.rebuild_derived_fields(); + + // timeslot_idx=100 should be filtered to None (only 2 timeslots exist) + assert_eq!(plan.lessons[0].timeslot_idx, None); + // room_idx=100 should be filtered to None (only 2 rooms exist) + assert_eq!(plan.lessons[0].room_idx, None); + // Valid indices should remain + assert_eq!(plan.lessons[1].timeslot_idx, Some(1)); + assert_eq!(plan.lessons[1].room_idx, Some(1)); + } + + #[test] + fn test_rebuild_derived_fields_restores_lesson_indexes() { + use chrono::NaiveTime; + + let timeslots = vec![Timeslot::new( + 0, + Weekday::Mon, + NaiveTime::from_hms_opt(8, 0, 0).unwrap(), + NaiveTime::from_hms_opt(10, 0, 0).unwrap(), + )]; + let lessons = vec![ + Lesson::new(0, "Math".to_string(), 0, None, 120), + Lesson::new(1, "Physics".to_string(), 0, None, 120), + ]; + let mut plan = Plan::new(timeslots, vec![], vec![], lessons, vec![]); + + plan.lessons[0].index = 0; + plan.lessons[1].index = 0; + plan.rebuild_derived_fields(); + + assert_eq!(plan.lessons[0].index, 0); + assert_eq!(plan.lessons[1].index, 1); + } + + #[test] + fn test_getters_return_none_for_invalid_indices() { + use chrono::NaiveTime; + + let timeslots = vec![Timeslot::new(0, Weekday::Mon, NaiveTime::from_hms_opt(8, 0, 0).unwrap(), NaiveTime::from_hms_opt(10, 0, 0).unwrap())]; + let teachers = vec![Teacher::new(0, "Teacher A", [true; 10])]; + let groups = vec![Group::new(0, "Group A", 30, [true; 10])]; + let rooms = vec![Room::new(0, "Room A")]; + let lessons = vec![]; + + let plan = Plan::new(timeslots, teachers, groups, lessons, rooms); + + // Valid indices + assert!(plan.get_timeslot(0).is_some()); + assert!(plan.get_teacher(0).is_some()); + assert!(plan.get_group(0).is_some()); + assert!(plan.get_room(0).is_some()); + + // Out of bounds indices + assert!(plan.get_timeslot(100).is_none()); + assert!(plan.get_teacher(100).is_none()); + assert!(plan.get_group(100).is_none()); + assert!(plan.get_room(100).is_none()); + } +} diff --git a/src/domain/room.rs b/src/domain/room.rs new file mode 100644 index 0000000000000000000000000000000000000000..d92ad7b929e98b308adf54c702947898c38da429 --- /dev/null +++ b/src/domain/room.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +/// The type of teaching space a lesson can require. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum RoomKind { + #[default] + Lecture, + Lab, + Computer, + Language, +} + +/// A physical teaching space available to the timetable. +#[problem_fact] +#[derive(Serialize, Deserialize)] +pub struct Room { + #[planning_id] + pub id: String, + #[serde(skip)] + pub index: usize, // the solver-facing join key + pub name: String, + pub kind: RoomKind, + pub capacity: usize, +} + +impl Room { + pub fn new(index: usize, name: impl Into) -> Self { + Self::with_kind_capacity(index, name, RoomKind::Lecture, 40) + } + + pub fn with_kind_capacity( + index: usize, + name: impl Into, + kind: RoomKind, + capacity: usize, + ) -> Self { + Self { + id: format!("room-{index}"), + index, + name: name.into(), + kind, + capacity, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_room_construction() { + let fact = Room::with_kind_capacity(0, "test", RoomKind::Lecture, 40); + assert_eq!(fact.id, "room-0"); + assert_eq!(fact.name, "test"); + assert_eq!(fact.kind, RoomKind::Lecture); + assert_eq!(fact.capacity, 40); + } +} diff --git a/src/domain/teacher.rs b/src/domain/teacher.rs new file mode 100644 index 0000000000000000000000000000000000000000..52938f26e0a798d82be98f1f81a6d096c0f9c564 --- /dev/null +++ b/src/domain/teacher.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +/// A teacher with a weekly availability calendar. +#[problem_fact] +#[derive(Serialize, Deserialize)] +pub struct Teacher { + #[planning_id] + pub id: String, + #[serde(skip)] + pub index: usize, // the solver-facing join key + pub name: String, + pub availability: Vec, +} + +impl Teacher { + pub fn new(index: usize, name: impl Into, availability: impl Into>) -> Self { + Self { + id: format!("teacher-{index}"), + index, + name: name.into(), + availability: availability.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_teacher_construction() { + let fact = Teacher::new(0, "test", vec![true; 40]); + assert_eq!(fact.index, 0); + assert_eq!(fact.id, "teacher-0"); + assert_eq!(fact.name, "test"); + let _ = &fact.name; + let _ = &fact.availability; + } +} diff --git a/src/domain/timeslot.rs b/src/domain/timeslot.rs new file mode 100644 index 0000000000000000000000000000000000000000..e36667283963106acadcffd301238a57a5260965 --- /dev/null +++ b/src/domain/timeslot.rs @@ -0,0 +1,50 @@ +use super::Weekday; +use chrono::NaiveTime; +use serde::{Deserialize, Serialize}; +use solverforge::prelude::*; + +/// A candidate teaching period that the solver can assign to a lesson. +#[problem_fact] +#[derive(Default, Serialize, Deserialize)] +pub struct Timeslot { + #[planning_id] + pub id: String, + #[serde(skip)] + pub index: usize, // the solver-facing join key + pub day_of_week: Weekday, + pub start_time: NaiveTime, + pub end_time: NaiveTime, +} + +impl Timeslot { + pub fn new( + index: usize, + day_of_week: Weekday, + start_time: NaiveTime, + end_time: NaiveTime, + ) -> Self { + Self { + id: format!("timeslot-{index}"), + index, + day_of_week, + start_time, + end_time, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_timeslot_construction() { + let fact = Timeslot::new(0, Weekday::Mon, Default::default(), Default::default()); + assert_eq!(fact.index, 0); + assert_eq!(fact.id, "timeslot-0"); + + let _ = &fact.day_of_week; + let _ = &fact.start_time; + let _ = &fact.end_time; + } +} diff --git a/src/domain/weekday.rs b/src/domain/weekday.rs new file mode 100644 index 0000000000000000000000000000000000000000..8adb3f9d67b5ca925d909117c57ae52d81d652b0 --- /dev/null +++ b/src/domain/weekday.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +/// School-day enum used by generated data and SolverForge planning facts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum Weekday { + #[default] + Mon, + Tue, + Wed, + Thu, + Fri, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..2c2822bd7e71e4829c9c80bd6de41479040b39ea --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +//! SolverForge lesson-timetabling application. +//! +//! The crate follows the same teaching shape as the other use cases: `domain` +//! defines the planning model, `constraints` defines scoring, `data` builds the +//! deterministic timetable instance, `solver` owns retained runtime jobs, and +//! `api` exposes the browser-facing HTTP surface. + +pub mod api; +pub mod constraints; +pub mod data; +pub mod domain; +pub mod solver; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..36dceec753edbed08484e8808f4d12ce9631607d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,44 @@ +//! Axum entrypoint for the lesson-timetabling app. +//! +//! Run with `make run-release`, then open the printed local URL. The same +//! binary is used by the Docker Space image, where `PORT` is provided by the +//! platform. + +use solverforge_lessons::api; + +use std::net::SocketAddr; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; + +#[tokio::main] +async fn main() { + // Use the stock SolverForge console logger so solve progress appears in + // local runs and Space container logs. + solverforge::console::init(); + + let state = Arc::new(api::AppState::new()); + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = api::router(state) + .merge(solverforge_ui::routes()) + .fallback_service(ServeDir::new("static")) + .layer(cors); + + // Hugging Face Spaces inject `PORT`; 7860 remains the local default used in + // docs, tests, and the Makefile. + let port = std::env::var("PORT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(7860); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + println!("â–¸ solverforge-lessons listening on http://{}", addr); + println!("â–¸ Open http://localhost:{} in your browser\n", port); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/src/solver/event_payload.rs b/src/solver/event_payload.rs new file mode 100644 index 0000000000000000000000000000000000000000..5598b2e3893678133164056c374296334ab24334 --- /dev/null +++ b/src/solver/event_payload.rs @@ -0,0 +1,205 @@ +//! JSON event payloads sent over the lessons SSE stream. +//! +//! SolverForge emits strongly typed lifecycle events. This module converts them +//! to the stable camelCase JSON shape consumed by the browser status bar and +//! timetable renderer. + +use serde::Serialize; +use std::time::Duration; + +use solverforge::{ + HardMediumSoftScore, SolverEventMetadata, SolverLifecycleState, SolverSnapshot, SolverStatus, + SolverTelemetry, SolverTerminalReason, +}; + +use crate::api::PlanDto; +use crate::domain::Plan; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct TelemetryPayload { + elapsed_ms: u64, + step_count: u64, + moves_generated: u64, + moves_evaluated: u64, + moves_accepted: u64, + score_calculations: u64, + generation_ms: u64, + evaluation_ms: u64, + moves_per_second: u64, + acceptance_rate: f64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JobEventPayload { + id: String, + job_id: String, + event_type: &'static str, + event_sequence: u64, + lifecycle_state: &'static str, + terminal_reason: Option<&'static str>, + telemetry: TelemetryPayload, + current_score: Option, + best_score: Option, + snapshot_revision: Option, + solution: Option, + error: Option, +} + +pub(super) fn status_event_payload( + job_id: usize, + event_type: &'static str, + status: &SolverStatus, +) -> String { + serialize_payload(JobEventPayload { + id: job_id.to_string(), + job_id: job_id.to_string(), + event_type, + event_sequence: status.event_sequence, + lifecycle_state: lifecycle_state_label(status.lifecycle_state), + terminal_reason: status.terminal_reason.map(terminal_reason_label), + telemetry: telemetry_payload(&status.telemetry), + current_score: status.current_score.map(|score| score.to_string()), + best_score: status.best_score.map(|score| score.to_string()), + snapshot_revision: status.latest_snapshot_revision, + solution: None, + error: None, + }) +} + +pub(super) fn snapshot_status_event_payload( + job_id: usize, + event_type: &'static str, + status: &SolverStatus, + snapshot: &SolverSnapshot, +) -> String { + serialize_payload(JobEventPayload { + id: job_id.to_string(), + job_id: job_id.to_string(), + event_type, + event_sequence: status.event_sequence, + lifecycle_state: lifecycle_state_label(status.lifecycle_state), + terminal_reason: status.terminal_reason.map(terminal_reason_label), + telemetry: telemetry_payload(&status.telemetry), + current_score: status + .current_score + .or(snapshot.current_score) + .map(|score| score.to_string()), + best_score: status + .best_score + .or(snapshot.best_score) + .map(|score| score.to_string()), + snapshot_revision: Some(snapshot.snapshot_revision), + solution: Some(PlanDto::from_plan(&snapshot.solution)), + error: None, + }) +} + +pub(super) fn event_payload( + job_id: usize, + event_type: &'static str, + metadata: &SolverEventMetadata, + solution: Option<&Plan>, + error: Option<&str>, +) -> String { + serialize_payload(JobEventPayload { + id: job_id.to_string(), + job_id: job_id.to_string(), + event_type, + event_sequence: metadata.event_sequence, + lifecycle_state: lifecycle_state_label(metadata.lifecycle_state), + terminal_reason: metadata.terminal_reason.map(terminal_reason_label), + telemetry: telemetry_payload(&metadata.telemetry), + current_score: metadata.current_score.map(|score| score.to_string()), + best_score: metadata.best_score.map(|score| score.to_string()), + snapshot_revision: metadata.snapshot_revision, + solution: solution.map(PlanDto::from_plan), + error: error.map(ToOwned::to_owned), + }) +} + +pub(super) fn bootstrap_event_type(state: SolverLifecycleState) -> &'static str { + match state { + SolverLifecycleState::Solving => "progress", + SolverLifecycleState::PauseRequested => "pause_requested", + SolverLifecycleState::Paused => "paused", + SolverLifecycleState::Completed => "completed", + SolverLifecycleState::Cancelled => "cancelled", + SolverLifecycleState::Failed => "failed", + } +} + +pub(super) fn bootstrap_snapshot_event_type(state: SolverLifecycleState) -> &'static str { + match state { + SolverLifecycleState::Solving => "best_solution", + other => bootstrap_event_type(other), + } +} + +fn serialize_payload(payload: JobEventPayload) -> String { + serde_json::to_string(&payload).expect("failed to serialize solver lifecycle payload") +} + +fn telemetry_payload(telemetry: &SolverTelemetry) -> TelemetryPayload { + TelemetryPayload { + elapsed_ms: duration_to_millis(telemetry.elapsed), + step_count: telemetry.step_count, + moves_generated: telemetry.moves_generated, + moves_evaluated: telemetry.moves_evaluated, + moves_accepted: telemetry.moves_accepted, + score_calculations: telemetry.score_calculations, + generation_ms: duration_to_millis(telemetry.generation_time), + evaluation_ms: duration_to_millis(telemetry.evaluation_time), + moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed), + acceptance_rate: derive_acceptance_rate( + telemetry.moves_accepted, + telemetry.moves_evaluated, + ), + } +} + +fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str { + match state { + SolverLifecycleState::Solving => "SOLVING", + SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED", + SolverLifecycleState::Paused => "PAUSED", + SolverLifecycleState::Completed => "COMPLETED", + SolverLifecycleState::Cancelled => "CANCELLED", + SolverLifecycleState::Failed => "FAILED", + } +} + +fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str { + match reason { + SolverTerminalReason::Completed => "completed", + SolverTerminalReason::TerminatedByConfig => "terminated_by_config", + SolverTerminalReason::Cancelled => "cancelled", + SolverTerminalReason::Failed => "failed", + } +} + +fn duration_to_millis(duration: Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + +fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 { + let nanos = elapsed.as_nanos(); + if nanos == 0 { + 0 + } else { + let per_second = u128::from(count) + .saturating_mul(1_000_000_000) + .checked_div(nanos) + .unwrap_or(0); + per_second.min(u128::from(u64::MAX)) as u64 + } +} + +fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 { + if moves_evaluated == 0 { + 0.0 + } else { + moves_accepted as f64 / moves_evaluated as f64 + } +} diff --git a/src/solver/mod.rs b/src/solver/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..10a3a86d8bd809bb54d83416b1992d33d5a39c23 --- /dev/null +++ b/src/solver/mod.rs @@ -0,0 +1,10 @@ +//! Solver-runtime facade exports for the lesson-timetabling app. +//! +//! Keeping the retained runtime behind `SolverService` prevents HTTP handlers +//! from depending directly on `SolverManager`. + +mod event_payload; +mod service; + +pub use service::SolverService; +pub use solverforge::SolverStatus; diff --git a/src/solver/service.rs b/src/solver/service.rs new file mode 100644 index 0000000000000000000000000000000000000000..3c4a4694ed796b012acdb2bbc8858ded91189c73 --- /dev/null +++ b/src/solver/service.rs @@ -0,0 +1,189 @@ +//! Retained-job orchestration for lesson timetable solves. +//! +//! SolverForge owns search and scoring. This service owns app-level concerns: +//! registering SSE broadcasters, translating public string ids to runtime job +//! ids, and exposing pause/resume/cancel/delete to the API layer. + +use parking_lot::RwLock; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{broadcast, mpsc}; + +use solverforge::{ + HardMediumSoftScore, SolverEvent, SolverManager, SolverManagerError, SolverSnapshot, + SolverSnapshotAnalysis, SolverStatus, +}; + +use super::event_payload::{ + bootstrap_event_type, bootstrap_snapshot_event_type, event_payload, + snapshot_status_event_payload, status_event_payload, +}; +use crate::domain::Plan; + +// The retained runtime needs a manager with `'static` lifetime because jobs can +// continue after the HTTP handler that started them has returned. +static MANAGER: SolverManager = SolverManager::new(); + +struct JobState { + sse_tx: broadcast::Sender, +} + +/// Manages retained solving jobs and broadcasts lifecycle-complete SSE payloads. +pub struct SolverService { + jobs: Arc>>, +} + +impl SolverService { + /// Creates an empty job registry. The underlying runtime itself is global. + pub fn new() -> Self { + Self { + jobs: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Starts a retained solve and registers the SSE broadcaster for that job. + pub fn start_job(&self, plan: Plan) -> Result { + let (job_id, receiver) = MANAGER.solve(plan)?; + let (sse_tx, _) = broadcast::channel(64); + + self.jobs.write().insert( + job_id, + JobState { + sse_tx: sse_tx.clone(), + }, + ); + + let jobs = Arc::clone(&self.jobs); + tokio::spawn(async move { + drain_receiver(jobs, job_id, sse_tx, receiver).await; + }); + + Ok(job_id.to_string()) + } + + /// Subscribes a browser client to future live events for a retained job. + pub fn subscribe(&self, id: &str) -> Option> { + let job_id = parse_job_id(id).ok()?; + self.jobs + .read() + .get(&job_id) + .map(|state| state.sse_tx.subscribe()) + } + + /// Builds the first SSE payload a client should receive after connecting. + pub fn bootstrap_event(&self, id: &str) -> Result { + let job_id = parse_job_id(id)?; + let status = MANAGER.get_status(job_id)?; + if let Some(revision) = status.latest_snapshot_revision { + let snapshot = MANAGER.get_snapshot(job_id, Some(revision))?; + return Ok(snapshot_status_event_payload( + job_id, + bootstrap_snapshot_event_type(status.lifecycle_state), + &status, + &snapshot, + )); + } + + Ok(status_event_payload( + job_id, + bootstrap_event_type(status.lifecycle_state), + &status, + )) + } + + pub fn get_status( + &self, + id: &str, + ) -> Result, SolverManagerError> { + let job_id = parse_job_id(id)?; + MANAGER.get_status(job_id) + } + + pub fn pause(&self, id: &str) -> Result<(), SolverManagerError> { + MANAGER.pause(parse_job_id(id)?) + } + + pub fn resume(&self, id: &str) -> Result<(), SolverManagerError> { + MANAGER.resume(parse_job_id(id)?) + } + + pub fn cancel(&self, id: &str) -> Result<(), SolverManagerError> { + MANAGER.cancel(parse_job_id(id)?) + } + + pub fn delete(&self, id: &str) -> Result<(), SolverManagerError> { + let job_id = parse_job_id(id)?; + MANAGER.delete(job_id)?; + self.jobs.write().remove(&job_id); + Ok(()) + } + + pub fn get_snapshot( + &self, + id: &str, + snapshot_revision: Option, + ) -> Result, SolverManagerError> { + MANAGER.get_snapshot(parse_job_id(id)?, snapshot_revision) + } + + pub fn analyze_snapshot( + &self, + id: &str, + snapshot_revision: Option, + ) -> Result, SolverManagerError> { + MANAGER.analyze_snapshot(parse_job_id(id)?, snapshot_revision) + } +} + +async fn drain_receiver( + jobs: Arc>>, + job_id: usize, + sse_tx: broadcast::Sender, + mut receiver: mpsc::UnboundedReceiver>, +) { + while let Some(event) = receiver.recv().await { + let payload = match &event { + SolverEvent::Progress { metadata } => { + event_payload(job_id, "progress", metadata, None, None) + } + SolverEvent::BestSolution { metadata, solution } => { + event_payload(job_id, "best_solution", metadata, Some(solution), None) + } + SolverEvent::PauseRequested { metadata } => { + event_payload(job_id, "pause_requested", metadata, None, None) + } + SolverEvent::Paused { metadata } => { + event_payload(job_id, "paused", metadata, None, None) + } + SolverEvent::Resumed { metadata } => { + event_payload(job_id, "resumed", metadata, None, None) + } + SolverEvent::Completed { metadata, solution } => { + event_payload(job_id, "completed", metadata, Some(solution), None) + } + SolverEvent::Cancelled { metadata } => { + event_payload(job_id, "cancelled", metadata, None, None) + } + SolverEvent::Failed { metadata, error } => { + event_payload(job_id, "failed", metadata, None, Some(error.as_str())) + } + }; + + if !jobs.read().contains_key(&job_id) { + return; + } + + let _ = sse_tx.send(payload); + } +} + +fn parse_job_id(id: &str) -> Result { + id.parse::() + .map_err(|_| SolverManagerError::JobNotFound { job_id: usize::MAX }) +} + +impl Default for SolverService { + fn default() -> Self { + Self::new() + } +} diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000000000000000000000000000000000000..47c3b0848aef83cf237c12005d3a830807b1472d --- /dev/null +++ b/static/app.css @@ -0,0 +1,98 @@ +html, +body { + min-height: 100%; + margin: 0; + background: var(--sf-color-background); + color: var(--sf-color-text); + color-scheme: light; +} + +.solverforge-lessons-app { + min-height: 100vh; + background: var(--sf-color-background); + color: var(--sf-color-text); +} + +.solverforge-lessons-app .sf-content { + padding: var(--sf-space-4) var(--sf-space-6); + background: var(--sf-color-background); + color: var(--sf-color-text); +} + +.solverforge-lessons-app .sf-content > p { + margin: 0 0 var(--sf-space-4); + color: var(--sf-color-text-secondary); +} + +.solverforge-lessons-app .sf-table-container, +.solverforge-lessons-app .sf-table, +.solverforge-lessons-app .sf-table tbody, +.solverforge-lessons-app .sf-table td { + background: var(--sf-color-surface); + color: var(--sf-color-text); +} + +.solverforge-lessons-app .sf-table th { + color: var(--sf-gray-700); +} + +.solverforge-lessons-app .sf-rail-timeline-toolbar { + padding: 14px 18px; +} + +.solverforge-lessons-app .sf-rail-timeline-lane-label { + gap: 6px; + justify-content: center; + padding: 10px 14px; +} + +.solverforge-lessons-app .sf-rail-timeline-lane-mode { + display: none; +} + +.solverforge-lessons-app .sf-rail-timeline-lane-heading { + align-items: center; +} + +.solverforge-lessons-app .sf-rail-timeline-lane-title { + font-size: 12px; +} + +.solverforge-lessons-app .sf-rail-timeline-lane-badges { + gap: 4px; +} + +.solverforge-lessons-app .sf-rail-timeline-lane-badge { + font-size: 8px; + padding: 2px 6px; +} + +.solverforge-lessons-app .sf-rail-timeline-row--detailed .sf-rail-timeline-item--detail { + box-shadow: + 0 12px 20px rgba(15, 23, 42, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.58); + height: 42px !important; + min-width: 86px; +} + +.solverforge-lessons-app .sf-rail-timeline .sf-block-label { + font-size: 12px; + font-weight: var(--sf-font-bold); + line-height: 1.15; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.solverforge-lessons-app .sf-rail-timeline .sf-block-meta { + font-size: 10px; + line-height: 1.2; + opacity: 0.9; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.solverforge-lessons-app .sf-rail-timeline-tooltip { + max-width: 340px; +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000000000000000000000000000000000000..76831973576297b9baf884de8a64e39ba7633213 --- /dev/null +++ b/static/app.js @@ -0,0 +1,877 @@ +/* app.js — solverforge-lessons SolverForge UI */ + +// biome-ignore-all lint: don't lint +import { renderByGroup, renderByRoom, renderByTeacher } from './views.js?v=20260514d'; + +'use strict'; + +var SLOT_MINUTES = 60; +var DEFAULT_VIEWPORT_SLOTS = 12; +var TIMELINE_TONES = ['emerald', 'blue', 'amber', 'rose', 'violet', 'slate']; + +var config = await fetch('/sf-config.json').then(function (response) { return response.json(); }); +var uiModel = await fetch('/generated/ui-model.json').then(function (response) { return response.json(); }); +var app = document.getElementById('sf-app'); +app.className = 'sf-app solverforge-lessons-app'; +var backend = SF.createBackend({ baseUrl: '' }); +var statusBar = SF.createStatusBar({ + constraints: buildStatusBarConstraints(uiModel.constraints || config.constraints || []), + onConstraintClick: function () { openAnalysis(); }, +}); +var currentPlan = null; +var lastAnalysis = null; +var bootstrapError = null; +var demoCatalog = { defaultId: null, availableIds: [] }; +var activeTab = 'by-group'; +var viewPanels = {}; +var viewTimelines = {}; + +// var tabs = (uiModel.views || []).map(function (view, index) { +// return { +// id: view.id, +// label: view.label, +// icon: view.kind === 'list' ? 'fa-list-ol' : 'fa-table-cells-large', +// active: index === 0, +// }; +// }); +// if (!tabs.length) { +// tabs.push({ id: 'overview', label: 'Overview', icon: 'fa-compass', active: true }); +// } +var tabs = [] +tabs.push({ id: 'by-group', label: 'By Group', icon: 'fa-users', active: true }); +tabs.push({ id: 'by-room', label: 'By Room', icon: 'fa-door-open' }); +tabs.push({ id: 'by-teacher', label: 'By Teacher', icon: 'fa-chalkboard-user' }); +tabs.push({ id: 'data', label: 'Data', icon: 'fa-table' }); +tabs.push({ id: 'api', label: 'REST API', icon: 'fa-book' }); + +var header = SF.createHeader({ + logo: '/sf/img/ouroboros.svg', + title: config.title, + subtitle: config.subtitle, + tabs: tabs, + actions: { + onSolve: function () { loadAndSolve(); }, + onPause: function () { pauseSolve(); }, + onResume: function () { resumeSolve(); }, + onCancel: function () { cancelSolve(); }, + onAnalyze: function () { openAnalysis(); }, + }, + onTabChange: function (tab) { + activeTab = tab; + Object.keys(viewPanels).forEach(function (key) { + viewPanels[key].style.display = key === tab ? '' : 'none'; + }); + overviewPanel.style.display = tab === 'overview' ? '' : 'none'; + dataPanel.style.display = tab === 'data' ? '' : 'none'; + apiPanel.style.display = tab === 'api' ? '' : 'none'; + byGroupPanel.style.display = tab === 'by-group' ? '' : 'none'; + byRoomPanel.style.display = tab === 'by-room' ? '' : 'none'; + byTeacherPanel.style.display = tab === 'by-teacher' ? '' : 'none'; + }, +}); +app.appendChild(header); +statusBar.bindHeader(header); +app.appendChild(statusBar.el); + +var bootstrapNotice = SF.el('div', { + className: 'sf-content', + style: { + display: 'none', + padding: '16px', + marginBottom: '16px', + borderRadius: '12px', + border: '1px solid #dc2626', + background: '#fef2f2', + color: '#991b1b', + }, +}); +app.appendChild(bootstrapNotice); + +var overviewPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'overview' ? '' : 'none' } }); +var overviewContainer = SF.el('div', { id: 'sf-overview' }); +overviewPanel.appendChild(overviewContainer); +app.appendChild(overviewPanel); + +(uiModel.views || []).forEach(function (view) { + var panel = SF.el('div', { className: 'sf-content', style: { display: activeTab === view.id ? '' : 'none' } }); + panel.appendChild(SF.el('div', { id: 'view-' + view.id })); + viewPanels[view.id] = panel; + app.appendChild(panel); +}); + +var dataPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'data' ? '' : 'none' } }); +var tablesContainer = SF.el('div', { id: 'sf-tables' }); +dataPanel.appendChild(tablesContainer); +app.appendChild(dataPanel); + +var apiPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'api' ? '' : 'none' } }); +var apiGuideContainer = SF.el('div'); +apiPanel.appendChild(apiGuideContainer); +app.appendChild(apiPanel); + +// Custom view panels +var byGroupPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'by-group' ? '' : 'none' } }); +var byGroupContainer = SF.el('div', { id: 'sf-by-group' }); +byGroupPanel.appendChild(byGroupContainer); +app.appendChild(byGroupPanel); +viewPanels['by-group'] = byGroupPanel; + +var byRoomPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'by-room' ? '' : 'none' } }); +var byRoomContainer = SF.el('div', { id: 'sf-by-room' }); +byRoomPanel.appendChild(byRoomContainer); +app.appendChild(byRoomPanel); +viewPanels['by-room'] = byRoomPanel; + +var byTeacherPanel = SF.el('div', { className: 'sf-content', style: { display: activeTab === 'by-teacher' ? '' : 'none' } }); +var byTeacherContainer = SF.el('div', { id: 'sf-by-teacher' }); +byTeacherPanel.appendChild(byTeacherContainer); +app.appendChild(byTeacherPanel); +viewPanels['by-teacher'] = byTeacherPanel; + +app.appendChild(SF.createFooter({ + links: [ + { label: 'SolverForge', url: 'https://www.solverforge.org' }, + { label: 'Docs', url: 'https://www.solverforge.org/docs' }, + ], +})); + +var analysisModal = SF.createModal({ title: 'Score Analysis', width: '700px' }); +var solver = SF.createSolver({ + backend: backend, + statusBar: statusBar, + onProgress: function (meta) { + syncLifecycleMarkers(meta); + }, + onPauseRequested: function (meta) { + syncLifecycleMarkers(meta); + }, + onSolution: function (snapshot, meta) { + if (snapshot && snapshot.solution) { + renderAll(snapshot.solution); + } + syncLifecycleMarkers(meta); + }, + onPaused: function (snapshot, meta) { + if (snapshot && snapshot.solution) { + renderAll(snapshot.solution); + } + syncLifecycleMarkers(meta); + }, + onResumed: function (meta) { + syncLifecycleMarkers(meta); + }, + onCancelled: function (snapshot, meta) { + if (snapshot && snapshot.solution) { + renderAll(snapshot.solution); + } + syncLifecycleMarkers(meta); + }, + onComplete: function (snapshot, meta) { + if (snapshot && snapshot.solution) { + renderAll(snapshot.solution); + } + syncLifecycleMarkers(meta); + }, + onFailure: function (message, meta, snapshot, analysis) { + if (snapshot && snapshot.solution) { + renderAll(snapshot.solution); + } + if (analysis) { + lastAnalysis = analysis; + } + console.error('Solver job failed:', message); + syncLifecycleMarkers(meta); + }, + onAnalysis: function (analysis) { + lastAnalysis = analysis; + syncLifecycleMarkers(); + }, + onError: function (message) { + console.error('Solver lifecycle failed:', message); + syncLifecycleMarkers(); + }, +}); +renderApiGuide(); +updateSolveActionAvailability(); +bootstrapDemoData(); + +window.addEventListener('beforeunload', destroyAllTimelines); + +function loadAndSolve() { + if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED' || !canSolve()) return; + cleanupTerminalJob() + .then(function (data) { + return data || resolvePlanForSolve(); + }) + .then(function (data) { + return solver.start(data); + }) + .then(function () { + syncLifecycleMarkers(); + }) + .catch(function (err) { console.error('Solve start failed:', err); }); +} + +function pauseSolve() { + solver.pause() + .then(function () { syncLifecycleMarkers(); }) + .catch(function (err) { console.error('Pause failed:', err); }); +} + +function resumeSolve() { + solver.resume() + .then(function () { syncLifecycleMarkers(); }) + .catch(function (err) { console.error('Resume failed:', err); }); +} + +function cancelSolve() { + solver.cancel() + .then(function () { syncLifecycleMarkers(); }) + .catch(function (err) { console.error('Cancel failed:', err); }); +} + +function openAnalysis() { + if (!solver.getJobId()) return; + solver.analyzeSnapshot() + .then(function (analysis) { + lastAnalysis = analysis; + analysisModal.setBody(buildAnalysisHtml(analysis)); + analysisModal.open(); + }) + .catch(function () { }); +} + +function buildStatusBarConstraints(constraints) { + var scoreLevels = { + assign_timeslot: 'medium', + assign_room: 'medium', + teacher_availability: 'hard', + group_availability: 'hard', + room_kind: 'soft', + room_capacity: 'hard', + no_group_conflict: 'hard', + no_room_conflict: 'hard', + no_teacher_conflict: 'hard', + late_lesson: 'soft', + repeated_subject_day: 'soft', + }; + + return (constraints || []).map(function (constraint) { + var name = typeof constraint === 'string' ? constraint : constraint.name; + return { + name: title((name || '').replace(/_/g, ' ')), + type: scoreLevels[name] || 'hard', + }; + }); +} + +function renderAll(data) { + currentPlan = clonePlan(data); + renderOverview(data); + renderViews(data); + renderTables(data); + renderByGroup(data, byGroupContainer, SF, toneForKey, entityLabel, customTimelines); + renderByRoom(data, byRoomContainer, SF, toneForKey, entityLabel, customTimelines); + renderByTeacher(data, byTeacherContainer, SF, toneForKey, entityLabel, customTimelines); +} + +function resolvePlanForSolve() { + if (currentPlan) { + return Promise.resolve(clonePlan(currentPlan)); + } + if (!demoCatalog.defaultId) { + return Promise.reject(new Error('demo data catalog is unavailable')); + } + return fetchDemoPlan(demoCatalog.defaultId); +} + +function bootstrapDemoData() { + fetchDemoCatalog() + .then(function (catalog) { + demoCatalog = catalog; + clearBootstrapError(); + renderApiGuide(); + return fetchDemoPlan(catalog.defaultId); + }) + .then(function (data) { + renderAll(data); + updateSolveActionAvailability(); + }) + .catch(function (err) { + reportBootstrapError(err); + }); +} + +function fetchDemoCatalog() { + return requestJson('/demo-data', 'demo data catalog') + .then(function (catalog) { + if (!catalog || typeof catalog.defaultId !== 'string' || !Array.isArray(catalog.availableIds)) { + throw new Error('demo data catalog is missing defaultId or availableIds'); + } + if (catalog.availableIds.indexOf(catalog.defaultId) === -1) { + throw new Error('demo data catalog defaultId is not present in availableIds'); + } + return { + defaultId: catalog.defaultId, + availableIds: catalog.availableIds.slice(), + }; + }); +} + +function fetchDemoPlan(demoId) { + return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"'); +} + +function requestJson(path, label) { + return fetch(path) + .then(function (response) { + if (!response.ok) { + throw new Error(label + ' returned HTTP ' + response.status); + } + return response.json(); + }); +} + +function canSolve() { + return !bootstrapError && !!demoCatalog.defaultId; +} + +function reportBootstrapError(err) { + bootstrapError = describeError(err); + bootstrapNotice.textContent = 'Demo data bootstrap failed: ' + bootstrapError; + bootstrapNotice.style.display = ''; + app.dataset.bootstrapError = 'true'; + renderApiGuide(); + updateSolveActionAvailability(); + console.error('Demo data bootstrap failed:', err); +} + +function clearBootstrapError() { + bootstrapError = null; + bootstrapNotice.textContent = ''; + bootstrapNotice.style.display = 'none'; + delete app.dataset.bootstrapError; +} + +function describeError(err) { + if (err && err.message) { + return err.message; + } + return String(err || 'unknown error'); +} + +function updateSolveActionAvailability() { + var solveButton = findHeaderButton('Solve'); + var disabled = !canSolve(); + if (!solveButton) return; + solveButton.disabled = disabled; + solveButton.setAttribute('aria-disabled', disabled ? 'true' : 'false'); + solveButton.title = disabled + ? (bootstrapError ? 'Demo data bootstrap failed.' : 'Loading demo data catalog...') + : ''; +} + +function findHeaderButton(label) { + var buttons = header.querySelectorAll('button'); + for (var i = 0; i < buttons.length; i += 1) { + var text = (buttons[i].textContent || '').trim(); + if (text === label) { + return buttons[i]; + } + } + return null; +} + +function renderApiGuide() { + apiGuideContainer.innerHTML = ''; + apiGuideContainer.appendChild(SF.createApiGuide({ + endpoints: buildApiGuideEndpoints(), + })); +} + +function buildApiGuideEndpoints() { + var defaultDemoPath = demoCatalog.defaultId + ? '/demo-data/' + demoCatalog.defaultId + : '/demo-data/{defaultId}'; + return [ + { method: 'GET', path: '/demo-data', description: 'Discover the default and available demo data IDs', curl: buildCurlCommand('GET', '/demo-data') }, + { method: 'GET', path: defaultDemoPath, description: 'Fetch the discovered default demo data', curl: buildCurlCommand('GET', defaultDemoPath) }, + { method: 'POST', path: '/jobs', description: 'Create a retained solving job', curl: buildCurlCommand('POST', '/jobs', { json: true, data: '@plan.json' }) }, + { method: 'GET', path: '/jobs/{id}', description: 'Get current job summary', curl: buildCurlCommand('GET', '/jobs/{id}') }, + { method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch the latest retained snapshot', curl: buildCurlCommand('GET', '/jobs/{id}/snapshot') }, + { method: 'GET', path: '/jobs/{id}/analysis?snapshot_revision={n}', description: 'Analyze an exact snapshot revision', curl: buildCurlCommand('GET', '/jobs/{id}/analysis?snapshot_revision=3', { quoteUrl: true }) }, + { method: 'POST', path: '/jobs/{id}/pause', description: 'Request an exact runtime pause', curl: buildCurlCommand('POST', '/jobs/{id}/pause') }, + { method: 'POST', path: '/jobs/{id}/resume', description: 'Resume a paused retained job', curl: buildCurlCommand('POST', '/jobs/{id}/resume') }, + { method: 'POST', path: '/jobs/{id}/cancel', description: 'Cancel a live or paused job', curl: buildCurlCommand('POST', '/jobs/{id}/cancel') }, + { method: 'DELETE', path: '/jobs/{id}', description: 'Delete a terminal retained job', curl: buildCurlCommand('DELETE', '/jobs/{id}') }, + { method: 'GET', path: '/jobs/{id}/events', description: 'Stream job lifecycle updates (SSE)', curl: buildCurlCommand('GET', '/jobs/{id}/events', { stream: true }) }, + ]; +} + +function buildCurlCommand(method, path, options) { + var parts = ['curl']; + if (options && options.stream) { + parts.push('-N'); + } + if (method && method !== 'GET') { + parts.push('-X', method); + } + if (options && options.json) { + parts.push('-H', '"Content-Type: application/json"'); + } + + var url = buildApiUrl(path); + parts.push(options && options.quoteUrl ? '"' + url + '"' : url); + + if (options && options.data) { + parts.push('-d', options.data); + } + + return parts.join(' '); +} + +function buildApiUrl(path) { + return currentOrigin() + path; +} + +function currentOrigin() { + return window.location.origin || (window.location.protocol + '//' + window.location.host); +} + +function cleanupTerminalJob() { + var state = solver.getLifecycleState(); + if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) { + return Promise.resolve(null); + } + return solver.delete() + .then(function () { + lastAnalysis = null; + syncLifecycleMarkers(); + return null; + }) + .catch(function (err) { + console.error('Delete failed:', err); + throw err; + }); +} + +function syncLifecycleMarkers(meta) { + var jobId = solver.getJobId(); + var snapshotRevision = solver.getSnapshotRevision(); + var lifecycleState = meta && meta.lifecycleState ? meta.lifecycleState : solver.getLifecycleState(); + + if (jobId) { + app.dataset.jobId = String(jobId); + } else { + delete app.dataset.jobId; + } + if (snapshotRevision != null) { + app.dataset.snapshotRevision = String(snapshotRevision); + } else { + delete app.dataset.snapshotRevision; + } + if (lifecycleState && lifecycleState !== 'IDLE') { + app.dataset.lifecycleState = lifecycleState; + } else { + delete app.dataset.lifecycleState; + } + updateSolveActionAvailability(); +} + +function clonePlan(data) { + return JSON.parse(JSON.stringify(data)); +} + +function renderOverview(data) { + overviewContainer.innerHTML = ''; + if ((uiModel.views || []).length) { + overviewContainer.appendChild(SF.el( + 'p', + null, + 'The generated views now mount the standard solverforge-ui timeline surface for every planning variable declared in your project.' + )); + overviewContainer.appendChild(SF.createTable({ + columns: ['Active views', 'Constraints', 'Current score'], + rows: [[ + String(uiModel.views.length), + String((uiModel.constraints || []).length), + String(data.score || '—'), + ]], + })); + return; + } + overviewContainer.appendChild(SF.el('p', null, 'No planning variables are declared yet. Use `solverforge generate entity`, `generate fact`, and `generate variable` to shape the app.')); +} + +function renderViews(data) { + (uiModel.views || []).forEach(function (view) { + var container = document.getElementById('view-' + view.id); + if (!container) return; + if (view.kind === 'list') { + renderTimelinePanel( + container, + view.id, + buildListViewPayload(data, view), + 'This list-variable timeline will appear once the referenced facts and entities contain data.' + ); + } else { + renderTimelinePanel( + container, + view.id, + buildScalarViewPayload(data, view), + 'This scalar-variable timeline will appear once the referenced facts and entities contain data.' + ); + } + }); +} + +function renderTimelinePanel(container, viewId, payload, emptyMessage) { + container.innerHTML = ''; + if (!payload) { + destroyTimeline(viewId); + container.appendChild(SF.el('p', null, emptyMessage)); + return; + } + + container.appendChild(payload.summary); + container.appendChild(ensureTimeline(viewId, payload.timeline).el); +} + +function ensureTimeline(viewId, timelineConfig) { + var timeline = viewTimelines[viewId]; + if (!timeline) { + timeline = SF.rail.createTimeline(timelineConfig); + viewTimelines[viewId] = timeline; + return timeline; + } + + timeline.setModel(timelineConfig.model); + return timeline; +} + +function destroyTimeline(viewId) { + var timeline = viewTimelines[viewId]; + if (!timeline) return; + timeline.destroy(); + delete viewTimelines[viewId]; +} + +function destroyAllTimelines() { + Object.keys(viewTimelines).forEach(function (viewId) { + destroyTimeline(viewId); + }); + Object.keys(customTimelines || {}).forEach(function (key) { + if (customTimelines[key]) { + customTimelines[key].destroy(); + delete customTimelines[key]; + } + }); +} + +// Custom timelines registry +var customTimelines = {}; + +function buildScalarViewPayload(data, view) { + var entities = data[view.entityPlural] || []; + var facts = data[view.sourcePlural] || []; + if (!entities.length || !facts.length) return null; + + var byIndex = {}; + facts.forEach(function (fact, index) { + byIndex[index] = fact; + }); + + var assignments = facts.map(function () { return []; }); + var detached = []; + entities.forEach(function (entity) { + var idx = entity[view.variableField]; + if (idx == null || byIndex[idx] == null) { + detached.push(entity); + return; + } + assignments[idx].push(entity); + }); + + var peakLoad = assignments.reduce(function (maxCount, items) { + return Math.max(maxCount, items.length); + }, 0); + var horizon = Math.max(peakLoad, detached.length, 1); + var axis = buildSlotAxis(horizon); + var lanes = facts.map(function (fact, factIndex) { + var items = assignments[factIndex] || []; + return { + id: view.id + '-lane-' + factIndex, + label: String(factLabel(fact, factIndex)), + mode: 'detailed', + badges: items.length ? [] : ['Empty'], + stats: [{ label: title(view.entityPlural), value: items.length }], + items: items.map(function (entity, itemIndex) { + return buildTimelineItem( + view.id + '-fact-' + factIndex + '-entity-' + itemIndex, + itemIndex, + entityLabel(entity, itemIndex), + 'Assignment ' + String(itemIndex + 1), + entityLabel(entity, itemIndex) + ); + }), + }; + }); + + if (detached.length) { + lanes.push({ + id: view.id + '-detached', + label: view.allowsUnassigned ? 'Unassigned' : 'Unmapped', + mode: 'detailed', + badges: [view.allowsUnassigned ? 'Needs assignment' : 'Out of range'], + stats: [{ label: title(view.entityPlural), value: detached.length }], + items: detached.map(function (entity, itemIndex) { + return buildTimelineItem( + view.id + '-detached-' + itemIndex, + itemIndex, + entityLabel(entity, itemIndex), + view.allowsUnassigned ? 'Awaiting assignment' : 'Invalid source index', + entityLabel(entity, itemIndex) + ); + }), + }); + } + + return { + summary: buildSummarySection( + ['Source lanes', title(view.entityPlural), 'Peak load', 'Unassigned'], + [ + String(facts.length), + String(entities.length), + String(peakLoad), + String(detached.length), + ] + ), + timeline: { + label: title(view.sourcePlural), + labelWidth: 280, + title: view.label, + subtitle: title(view.entityPlural) + ' grouped by ' + title(view.sourcePlural), + model: { + axis: axis, + lanes: lanes, + }, + }, + }; +} + +function buildListViewPayload(data, view) { + var entities = data[view.entityPlural] || []; + var facts = data[view.sourcePlural] || []; + if (!entities.length || !facts.length) return null; + + var byIndex = {}; + facts.forEach(function (fact, index) { + byIndex[index] = fact; + }); + + var rows = entities.map(function (entity, entityIndex) { + var sequence = Array.isArray(entity[view.variableField]) ? entity[view.variableField] : []; + return { + entity: entity, + entityIndex: entityIndex, + sequence: sequence, + }; + }); + + rows.sort(function (left, right) { + if (right.sequence.length !== left.sequence.length) { + return right.sequence.length - left.sequence.length; + } + return String(entityLabel(left.entity, left.entityIndex)).localeCompare( + String(entityLabel(right.entity, right.entityIndex)) + ); + }); + + var totalItems = rows.reduce(function (sum, row) { + return sum + row.sequence.length; + }, 0); + var longestSequence = rows.reduce(function (maxCount, row) { + return Math.max(maxCount, row.sequence.length); + }, 0); + var emptyEntities = rows.filter(function (row) { return row.sequence.length === 0; }).length; + var horizon = Math.max(longestSequence, 1); + var axis = buildSlotAxis(horizon); + + var lanes = rows.map(function (row) { + return { + id: view.id + '-entity-' + row.entityIndex, + label: entityLabel(row.entity, row.entityIndex), + mode: 'detailed', + badges: listLaneBadges(row.sequence.length, longestSequence), + stats: [{ label: title(view.sourcePlural), value: row.sequence.length }], + items: row.sequence.map(function (factIndex, sequenceIndex) { + var fact = byIndex[factIndex]; + return buildTimelineItem( + view.id + '-entity-' + row.entityIndex + '-item-' + sequenceIndex, + sequenceIndex, + factLabel(fact, factIndex), + 'Position ' + String(sequenceIndex + 1), + factLabel(fact, factIndex) + ); + }), + }; + }); + + return { + summary: buildSummarySection( + [title(view.entityPlural), title(view.sourcePlural), 'Longest sequence', 'Empty lanes', 'Average items / lane'], + [ + String(rows.length), + String(totalItems), + String(longestSequence), + String(emptyEntities), + rows.length ? (totalItems / rows.length).toFixed(1) : '0.0', + ] + ), + timeline: { + label: title(view.entityPlural), + labelWidth: 280, + title: view.label, + subtitle: title(view.sourcePlural) + ' ordered inside each ' + title(view.entityPlural), + model: { + axis: axis, + lanes: lanes, + }, + }, + }; +} + +function buildSummarySection(columns, row) { + var section = SF.el('div', { className: 'sf-section' }); + section.appendChild(SF.createTable({ + columns: columns, + rows: [row], + })); + return section; +} + +function buildSlotAxis(slotCount) { + var normalizedSlots = Math.max(slotCount, 1); + var groupSize = normalizedSlots > 24 ? 8 : (normalizedSlots > 12 ? 6 : 4); + var days = []; + var ticks = []; + + for (var startSlot = 0; startSlot < normalizedSlots; startSlot += groupSize) { + var endSlot = Math.min(normalizedSlots, startSlot + groupSize); + days.push({ + id: 'window-' + startSlot, + label: 'Window ' + String(days.length + 1), + subLabel: slotRangeLabel(startSlot, endSlot), + startMinute: startSlot * SLOT_MINUTES, + endMinute: endSlot * SLOT_MINUTES, + }); + } + + for (var slotIndex = 0; slotIndex < normalizedSlots; slotIndex += 1) { + ticks.push({ + id: 'tick-' + slotIndex, + minute: slotIndex * SLOT_MINUTES, + label: 'Slot ' + String(slotIndex + 1), + }); + } + + return { + startMinute: 0, + endMinute: normalizedSlots * SLOT_MINUTES, + days: days, + ticks: ticks, + initialViewport: { + startMinute: 0, + endMinute: Math.min(normalizedSlots, DEFAULT_VIEWPORT_SLOTS) * SLOT_MINUTES, + }, + }; +} + +function buildTimelineItem(id, slotIndex, label, meta, toneKey) { + return { + id: id, + startMinute: slotIndex * SLOT_MINUTES, + endMinute: (slotIndex + 1) * SLOT_MINUTES, + label: String(label), + meta: meta || '', + tone: toneForKey(toneKey || label), + }; +} + +function slotRangeLabel(startSlot, endSlot) { + if (endSlot - startSlot <= 1) { + return 'Slot ' + String(startSlot + 1); + } + return 'Slots ' + String(startSlot + 1) + '-' + String(endSlot); +} + +function listLaneBadges(length, longestSequence) { + if (length === 0) return ['Empty']; + var badges = []; + if (length === longestSequence) badges.push('Longest'); + if (length === 1) badges.push('Single'); + return badges; +} + +function toneForKey(key) { + var text = String(key || ''); + var hash = 0; + + for (var index = 0; index < text.length; index += 1) { + hash = ((hash * 31) + text.charCodeAt(index)) >>> 0; + } + + return TIMELINE_TONES[hash % TIMELINE_TONES.length]; +} + +function renderTables(data) { + tablesContainer.innerHTML = ''; + (uiModel.entities || []).concat(uiModel.facts || []).forEach(function (entry) { + var rows = data[entry.plural] || []; + if (!rows.length) return; + var cols = Object.keys(rows[0]).filter(function (key) { return key !== 'score' && key !== 'solverStatus'; }); + var values = rows.map(function (row) { + return cols.map(function (key) { + var value = row[key]; + if (value == null) return '—'; + if (Array.isArray(value)) return value.join(', '); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + }); + }); + var section = SF.el('div', { className: 'sf-section' }); + section.appendChild(SF.el('h3', null, entry.label)); + section.appendChild(SF.createTable({ columns: cols, rows: values })); + tablesContainer.appendChild(section); + }); +} + + + +function buildAnalysisHtml(analysis) { + if (!analysis || !analysis.constraints) return '

No analysis available.

'; + var html = '

Score: ' + SF.escHtml(analysis.score) + '

'; + html += ''; + analysis.constraints.forEach(function (constraint) { + var matchCount = constraint.matchCount != null ? constraint.matchCount : (constraint.matches ? constraint.matches.length : 0); + html += ''; + }); + html += '
ConstraintTypeScoreMatches
' + SF.escHtml(constraint.name) + '' + SF.escHtml(constraint.constraintType || constraint.type || '') + '' + SF.escHtml(constraint.score) + '' + matchCount + '
'; + return html; +} + +function factLabel(fact, fallback) { + if (!fact) return String(fallback); + return fact.name || fact.id || fallback; +} + +function entityLabel(entity, fallback) { + if (!entity) return String(fallback); + return entity.name || entity.id || fallback; +} + +function title(text) { + return String(text || '') + .replace(/_/g, ' ') + .replace(/\b\w/g, function (match) { return match.toUpperCase(); }); +} diff --git a/static/generated/ui-model.json b/static/generated/ui-model.json new file mode 100644 index 0000000000000000000000000000000000000000..d84404f3f4e56bdd54fa726fc033b345d7151bd3 --- /dev/null +++ b/static/generated/ui-model.json @@ -0,0 +1,66 @@ +{ + "constraints": [ + "assign_timeslot", + "assign_room", + "teacher_availability", + "group_availability", + "room_kind", + "room_capacity", + "no_group_conflict", + "no_room_conflict", + "no_teacher_conflict", + "late_lesson", + "repeated_subject_day" + ], + "entities": [ + { + "label": "Lesson", + "name": "lesson", + "plural": "lessons" + } + ], + "facts": [ + { + "label": "Timeslot", + "name": "timeslot", + "plural": "timeslots" + }, + { + "label": "Teacher", + "name": "teacher", + "plural": "teachers" + }, + { + "label": "Group", + "name": "group", + "plural": "groups" + }, + { + "label": "Room", + "name": "room", + "plural": "rooms" + } + ], + "views": [ + { + "allowsUnassigned": false, + "entity": "lesson", + "entityPlural": "lessons", + "id": "lesson-timeslot_idx", + "kind": "scalar", + "label": "Lesson · timeslot_idx", + "sourcePlural": "timeslots", + "variableField": "timeslot_idx" + }, + { + "allowsUnassigned": false, + "entity": "lesson", + "entityPlural": "lessons", + "id": "lesson-room_idx", + "kind": "scalar", + "label": "Lesson · room_idx", + "sourcePlural": "rooms", + "variableField": "room_idx" + } + ] +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..70e5549c497f3855135234741b7a76af4a1ad5e6 --- /dev/null +++ b/static/index.html @@ -0,0 +1,18 @@ + + + + + + SolverForge Lessons + + + + + + + +
+ + + + diff --git a/static/sf-config.json b/static/sf-config.json new file mode 100644 index 0000000000000000000000000000000000000000..16963e28b6cfeb0929e68c3b29c06a27672f8567 --- /dev/null +++ b/static/sf-config.json @@ -0,0 +1,46 @@ +{ + "title": "SolverForge Lessons", + "subtitle": "Lesson timetable optimization", + "constraints": [ + "assign_timeslot", + "assign_room", + "teacher_availability", + "group_availability", + "room_kind", + "room_capacity", + "no_teacher_conflict", + "no_group_conflict", + "no_room_conflict", + "late_lesson", + "repeated_subject_day" + ], + "entities": [ + { + "name": "lesson", + "label": "Lesson", + "plural": "lessons" + } + ], + "facts": [ + { + "name": "timeslot", + "label": "Timeslot", + "plural": "timeslots" + }, + { + "name": "teacher", + "label": "Teacher", + "plural": "teachers" + }, + { + "name": "group", + "label": "Group", + "plural": "groups" + }, + { + "name": "room", + "label": "Room", + "plural": "rooms" + } + ] +} diff --git a/static/views.js b/static/views.js new file mode 100644 index 0000000000000000000000000000000000000000..233d2bbcb2daf72b021b342b4a22ec271d4bd79f --- /dev/null +++ b/static/views.js @@ -0,0 +1,513 @@ +/* views.js — Custom timeline views for Group, Room, and Teacher */ + +// biome-ignore-all lint: don't lint + +var SLOT_MINUTES = 60; + +// Mapping jours de la semaine +var DAY_MAP = { + Mon: 0, Monday: 0, + Tue: 1, Tuesday: 1, + Wed: 2, Wednesday: 2, + Thu: 3, Thursday: 3, + Fri: 4, Friday: 4, + Sat: 5, Saturday: 5, + Sun: 6, Sunday: 6, +}; +var WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; +var WEEKDAY_SHORT = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +// Parse une heure au format "HH:MM:SS" ou "HH:MM" en minutes depuis minuit +function parseTimeToMinutes(timeStr) { + if (!timeStr) return 0; + var parts = timeStr.split(':'); + var hours = parseInt(parts[0], 10) || 0; + var minutes = parseInt(parts[1], 10) || 0; + // Clamp to valid range (0-1439 for a single day) + return Math.max(0, Math.min(hours * 60 + minutes, 1439)); +} + +// Convertit un timeslot en minutes absolues (depuis Lundi 00:00) +function timeslotToMinutes(timeslot) { + if (!timeslot) return { startMinute: 0, endMinute: SLOT_MINUTES }; + var dayIndex = DAY_MAP[timeslot.day_of_week]; + if (dayIndex == null) dayIndex = 0; + var startMin = parseTimeToMinutes(timeslot.start_time); + var endMin = parseTimeToMinutes(timeslot.end_time); + + // Garantir que endMinute > startMinute + if (endMin <= startMin) { + endMin = startMin + SLOT_MINUTES; // Durée par défaut de 60 minutes + } + + return { + startMinute: dayIndex * 1440 + startMin, + endMinute: dayIndex * 1440 + endMin, + }; +} + +function formatClock(totalMinutes) { + var minutesInDay = ((totalMinutes % 1440) + 1440) % 1440; + var hours = Math.floor(minutesInDay / 60); + var minutes = minutesInDay % 60; + return String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0'); +} + +function weekdayIndex(day) { + var index = DAY_MAP[day]; + return index == null ? 0 : index; +} + +function weekdayShortLabel(day) { + return WEEKDAY_SHORT[weekdayIndex(day)] || String(day || 'Day'); +} + +function safeId(value) { + return String(value == null ? 'unknown' : value).replace(/[^A-Za-z0-9_-]+/g, '-'); +} + +function isAssignedIndex(index, collection) { + return Number.isInteger(index) && index >= 0 && index < collection.length; +} + +function assignedFact(collection, index) { + return isAssignedIndex(index, collection) ? collection[index] : null; +} + +function factLabel(fact, fallback) { + if (!fact) return fallback; + return fact.name || fact.id || fact.code || fallback; +} + +function scheduledBadges(totalCount, scheduledCount) { + if (!totalCount) return ['No lessons']; + var complete = scheduledCount === totalCount; + return [{ + label: scheduledCount + '/' + totalCount + ' scheduled', + style: complete ? { + bg: '#ecfdf5', + border: '1px solid #a7f3d0', + color: '#047857', + } : { + bg: '#fffbeb', + border: '1px solid #fde68a', + color: '#92400e', + }, + }]; +} + +function countScheduled(lessons, timeslots) { + return lessons.reduce(function (count, lesson) { + return assignedFact(timeslots, lesson.timeslot_idx) ? count + 1 : count; + }, 0); +} + +function teachingWindowLabel(timeslots) { + if (!timeslots || !timeslots.length) return 'No timetable'; + var minStart = 1440; + var maxEnd = 0; + timeslots.forEach(function (timeslot) { + minStart = Math.min(minStart, parseTimeToMinutes(timeslot.start_time)); + maxEnd = Math.max(maxEnd, parseTimeToMinutes(timeslot.end_time)); + }); + return 'Mon-Fri ' + formatClock(minStart) + '-' + formatClock(maxEnd); +} + +function formatTimeslot(timeslot) { + if (!timeslot) return 'Timeslot unassigned'; + return weekdayShortLabel(timeslot.day_of_week) + ' ' + + formatClock(parseTimeToMinutes(timeslot.start_time)) + '-' + + formatClock(parseTimeToMinutes(timeslot.end_time)); +} + +function buildLessonTimelineItem(prefix, lesson, lessonIndex, lookups, toneForKey, entityLabel) { + var timeslot = assignedFact(lookups.timeslots, lesson.timeslot_idx); + if (!timeslot) return null; + var room = assignedFact(lookups.rooms, lesson.room_idx); + var teacher = assignedFact(lookups.teachers, lesson.teacher_idx); + var group = assignedFact(lookups.groups, lesson.group_idx); + var tsMinutes = timeslotToMinutes(timeslot); + var subject = lesson.subject || entityLabel(lesson, lessonIndex); + return { + id: prefix + '-' + safeId(lesson.id || lessonIndex), + startMinute: tsMinutes.startMinute, + endMinute: tsMinutes.endMinute, + label: subject, + meta: [ + { label: 'Room', value: factLabel(room, 'Unassigned room') }, + { label: 'Teacher', value: factLabel(teacher, 'Unassigned teacher') }, + { label: 'Cohort', value: factLabel(group, 'Unassigned cohort') }, + { label: 'Period', value: formatTimeslot(timeslot) }, + { label: 'Students', value: String(lesson.student_count || '') }, + ], + tone: toneForKey(subject), + }; +} + +// Construire l'axe à partir des timeslots +function buildAxisFromTimeslots(timeslots) { + if (!timeslots || !timeslots.length) { + // Fallback : un seul jour de 8h à 18h + return { + startMinute: 0, + endMinute: 10 * 60, // 10 slots de 60 min + days: [{ id: 'day-0', label: 'Monday', subLabel: '08:00-18:00', startMinute: 0, endMinute: 1440, isWeekend: false }], + ticks: [], + initialViewport: { startMinute: 0, endMinute: 600 }, + }; + } + + // Déterminer quels jours sont présents + var presentDays = []; + timeslots.forEach(function (ts) { + var day = ts.day_of_week; + if (day && presentDays.indexOf(day) === -1) { + presentDays.push(day); + } + }); + presentDays.sort(function (a, b) { return DAY_MAP[a] - DAY_MAP[b]; }); + + var days = []; + var ticks = []; + var maxEndMinute = 0; + var minStartInDay = 1440; + var maxEndInDay = 0; + timeslots.forEach(function (ts) { + minStartInDay = Math.min(minStartInDay, parseTimeToMinutes(ts.start_time)); + maxEndInDay = Math.max(maxEndInDay, parseTimeToMinutes(ts.end_time)); + }); + if (minStartInDay >= maxEndInDay) { + minStartInDay = 8 * 60; + maxEndInDay = 18 * 60; + } + + // Créer les blocs "days" (un par jour) + presentDays.forEach(function (day) { + var dayIndex = DAY_MAP[day]; + var dayStart = dayIndex * 1440 + minStartInDay; + var dayEnd = dayIndex * 1440 + maxEndInDay; + days.push({ + id: 'day-' + day, + label: WEEKDAYS[dayIndex], + subLabel: formatClock(minStartInDay) + '-' + formatClock(maxEndInDay), + startMinute: dayStart, + endMinute: dayEnd, + isWeekend: day === 'Sat' || day === 'Sun', + }); + }); + + // Créer les ticks horaires à partir de la fenêtre d'enseignement réelle. + presentDays.forEach(function (day) { + var dayIndex = DAY_MAP[day]; + for (var h = Math.floor(minStartInDay / 60); h <= Math.ceil(maxEndInDay / 60); h += 2) { + ticks.push({ + id: 'tick-' + day + '-h' + h, + minute: dayIndex * 1440 + h * 60, + label: h + 'h', + }); + } + }); + + // Calculer la fin maximale à partir des timeslots + timeslots.forEach(function (ts) { + var end = DAY_MAP[ts.day_of_week] * 1440 + parseTimeToMinutes(ts.end_time); + maxEndMinute = Math.max(maxEndMinute, end); + }); + + // Si aucun timeslot n'a de jour valide, utiliser 5 jours par défaut + if (presentDays.length === 0) { + for (var d = 0; d < 5; d++) { + days.push({ + id: 'day-' + d, + label: WEEKDAYS[d], + startMinute: d * 1440, + endMinute: (d + 1) * 1440, + isWeekend: false, + }); + for (var h = 8; h <= 18; h += 2) { + ticks.push({ + id: 'tick-day' + d + '-h' + h, + minute: d * 1440 + h * 60, + label: h + 'h', + }); + } + } + maxEndMinute = 5 * 1440; + } + + return { + startMinute: days.length ? days[0].startMinute : 0, + endMinute: maxEndMinute, + days: days, + ticks: ticks, + initialViewport: { + startMinute: days.length ? days[0].startMinute : 0, + endMinute: days.length ? Math.min(maxEndMinute, days[0].endMinute) : Math.min(maxEndMinute, 10 * 60), + }, + }; +} + +function ensureCustomTimeline(key, customTimelines, SF, timelineConfig) { + var timeline = customTimelines[key]; + if (!timeline) { + timeline = SF.rail.createTimeline(timelineConfig); + customTimelines[key] = timeline; + return timeline; + } + timeline.setModel(timelineConfig.model); + return timeline; +} + +export function renderByGroup(data, container, SF, toneForKey, entityLabel, customTimelines) { + var lessons = data.lessons || []; + var groups = data.groups || []; + var timeslots = data.timeslots || []; + var rooms = data.rooms || []; + var teachers = data.teachers || []; + + console.log('renderByGroup: groups=' + groups.length + ', lessons=' + lessons.length); + + if (!lessons.length) { + container.innerHTML = '

No lessons available.

'; + return; + } + + // Créer une lane par groupe existant, même sans lessons + var byGroup = {}; + groups.forEach(function(group, idx) { + var groupKey = group.name || 'Group ' + idx; + byGroup[groupKey] = { group: group, lessons: [] }; + }); + + // Assigner les lessons aux groupes + lessons.forEach(function (lesson) { + var groupIdx = lesson.group_idx; + if (groupIdx == null || !groups[groupIdx]) { + // Lesson sans groupe : créer une lane "Unassigned" + var unassignedKey = 'Unassigned'; + if (!byGroup[unassignedKey]) { + byGroup[unassignedKey] = { group: { name: unassignedKey }, lessons: [] }; + } + byGroup[unassignedKey].lessons.push(lesson); + return; + } + var group = groups[groupIdx]; + var groupKey = group.name || 'Group ' + groupIdx; + if (!byGroup[groupKey]) { + byGroup[groupKey] = { group: group, lessons: [] }; + } + byGroup[groupKey].lessons.push(lesson); + }); + + var axis = buildAxisFromTimeslots(timeslots); + + var lanes = Object.entries(byGroup).map(function (entry) { + var groupKey = entry[0]; + var groupData = entry[1]; + var scheduledCount = countScheduled(groupData.lessons, timeslots); + var items = groupData.lessons.map(function (lesson, idx) { + return buildLessonTimelineItem( + 'group-' + safeId(groupKey), + lesson, + idx, + { timeslots: timeslots, rooms: rooms, teachers: teachers, groups: groups }, + toneForKey, + entityLabel + ); + }).filter(Boolean); + return { + id: 'group-' + safeId(groupKey), + label: groupKey + (groupData.group.code ? ' (' + groupData.group.code + ')' : ''), + mode: 'detailed', + badges: scheduledBadges(groupData.lessons.length, scheduledCount), + stats: [], + items: items, + }; + }); + + var timeline = ensureCustomTimeline('by-group', customTimelines, SF, { + label: 'Groups', + labelWidth: 240, + title: 'Cohort Timetables', + subtitle: 'Fixed Mon-Fri teaching week', + zoomPresets: [], + model: { axis: axis, lanes: lanes }, + }); + + container.innerHTML = ''; + var realGroupCount = Object.keys(byGroup).filter(function(key) { return key !== 'Unassigned'; }).length; + container.appendChild(SF.createTable({ + columns: ['Cohorts', 'Lessons', 'Scheduled', 'Window'], + rows: [[String(realGroupCount), String(lessons.length), String(countScheduled(lessons, timeslots)), teachingWindowLabel(timeslots)]], + })); + container.appendChild(timeline.el); +} + +export function renderByRoom(data, container, SF, toneForKey, entityLabel, customTimelines) { + var lessons = data.lessons || []; + var rooms = data.rooms || []; + var timeslots = data.timeslots || []; + var groups = data.groups || []; + var teachers = data.teachers || []; + + console.log('renderByRoom: rooms=' + rooms.length + ', lessons=' + lessons.length); + + if (!lessons.length) { + container.innerHTML = '

No lessons available.

'; + return; + } + + // Créer une lane par room existant, même sans lessons + var byRoom = {}; + rooms.forEach(function(room, idx) { + var roomKey = room.name || 'Room ' + idx; + byRoom[roomKey] = { room: room, lessons: [] }; + }); + + // Assigner les lessons aux rooms + lessons.forEach(function (lesson) { + var roomIdx = lesson.room_idx; + if (!assignedFact(rooms, roomIdx)) { + var unassignedKey = 'Unassigned room'; + if (!byRoom[unassignedKey]) { + byRoom[unassignedKey] = { room: { name: unassignedKey }, lessons: [] }; + } + byRoom[unassignedKey].lessons.push(lesson); + return; + } + var room = rooms[roomIdx]; + var roomKey = room.name || 'Room ' + roomIdx; + if (!byRoom[roomKey]) { + byRoom[roomKey] = { room: room, lessons: [] }; + } + byRoom[roomKey].lessons.push(lesson); + }); + + var axis = buildAxisFromTimeslots(timeslots); + + var lanes = Object.entries(byRoom).map(function (entry) { + var roomKey = entry[0]; + var roomData = entry[1]; + var scheduledCount = countScheduled(roomData.lessons, timeslots); + var items = roomData.lessons.map(function (lesson, idx) { + return buildLessonTimelineItem( + 'room-' + safeId(roomKey), + lesson, + idx, + { timeslots: timeslots, rooms: rooms, teachers: teachers, groups: groups }, + toneForKey, + entityLabel + ); + }).filter(Boolean); + return { + id: 'room-' + safeId(roomKey), + label: roomKey + (roomData.room.code ? ' (' + roomData.room.code + ')' : ''), + mode: 'detailed', + badges: roomData.lessons.length === 0 ? ['Empty'] : scheduledBadges(roomData.lessons.length, scheduledCount), + stats: [], + items: items, + }; + }); + + var timeline = ensureCustomTimeline('by-room', customTimelines, SF, { + label: 'Rooms', + labelWidth: 240, + title: 'Room Utilization', + subtitle: 'Fixed Mon-Fri teaching week', + zoomPresets: [], + model: { axis: axis, lanes: lanes }, + }); + + container.innerHTML = ''; + var realRoomCount = Object.keys(byRoom).filter(function(key) { return key !== 'Unassigned room'; }).length; + container.appendChild(SF.createTable({ + columns: ['Rooms', 'Lessons', 'Scheduled', 'Window'], + rows: [[String(realRoomCount), String(lessons.length), String(countScheduled(lessons, timeslots)), teachingWindowLabel(timeslots)]], + })); + container.appendChild(timeline.el); +} + +export function renderByTeacher(data, container, SF, toneForKey, entityLabel, customTimelines) { + var lessons = data.lessons || []; + var teachers = data.teachers || []; + var timeslots = data.timeslots || []; + var rooms = data.rooms || []; + var groups = data.groups || []; + + console.log('renderByTeacher: teachers=' + teachers.length + ', lessons=' + lessons.length); + + if (!lessons.length) { + container.innerHTML = '

No lessons available.

'; + return; + } + + // Créer une lane par teacher existant, même sans lessons + var byTeacher = {}; + teachers.forEach(function(teacher, idx) { + var teacherKey = teacher.name || 'Teacher ' + idx; + byTeacher[teacherKey] = { teacher: teacher, lessons: [] }; + }); + + // Assigner les lessons aux teachers + lessons.forEach(function (lesson) { + var teacherIdx = lesson.teacher_idx; + if (teacherIdx == null || !teachers[teacherIdx]) { + // Lesson sans teacher : créer une lane "Unassigned" + var unassignedKey = 'Unassigned'; + if (!byTeacher[unassignedKey]) { + byTeacher[unassignedKey] = { teacher: { name: unassignedKey }, lessons: [] }; + } + byTeacher[unassignedKey].lessons.push(lesson); + return; + } + var teacher = teachers[teacherIdx]; + var teacherKey = teacher.name || 'Teacher ' + teacherIdx; + if (!byTeacher[teacherKey]) { + byTeacher[teacherKey] = { teacher: teacher, lessons: [] }; + } + byTeacher[teacherKey].lessons.push(lesson); + }); + + var axis = buildAxisFromTimeslots(timeslots); + + var lanes = Object.entries(byTeacher).map(function (entry) { + var teacherKey = entry[0]; + var teacherData = entry[1]; + var scheduledCount = countScheduled(teacherData.lessons, timeslots); + var items = teacherData.lessons.map(function (lesson, idx) { + return buildLessonTimelineItem( + 'teacher-' + safeId(teacherKey), + lesson, + idx, + { timeslots: timeslots, rooms: rooms, teachers: teachers, groups: groups }, + toneForKey, + entityLabel + ); + }).filter(Boolean); + return { + id: 'teacher-' + safeId(teacherKey), + label: teacherKey + (teacherData.teacher.code ? ' (' + teacherData.teacher.code + ')' : ''), + mode: 'detailed', + badges: teacherData.lessons.length === 0 ? ['Empty'] : scheduledBadges(teacherData.lessons.length, scheduledCount), + stats: [], + items: items, + }; + }); + + var timeline = ensureCustomTimeline('by-teacher', customTimelines, SF, { + label: 'Teachers', + labelWidth: 240, + title: 'Teacher Loads', + subtitle: 'Fixed Mon-Fri teaching week', + zoomPresets: [], + model: { axis: axis, lanes: lanes }, + }); + + container.innerHTML = ''; + var realTeacherCount = Object.keys(byTeacher).filter(function(key) { return key !== 'Unassigned'; }).length; + container.appendChild(SF.createTable({ + columns: ['Teachers', 'Lessons', 'Scheduled', 'Window'], + rows: [[String(realTeacherCount), String(lessons.length), String(countScheduled(lessons, timeslots)), teachingWindowLabel(timeslots)]], + })); + container.appendChild(timeline.el); +}