From 29a276573f9912bd83ad366470a6a7b0c95e58a3 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 12:37:37 +0800 Subject: [PATCH 1/6] Add plan for #511: [Model] TimetableDesign --- docs/plans/2026-03-21-timetable-design.md | 294 ++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/plans/2026-03-21-timetable-design.md diff --git a/docs/plans/2026-03-21-timetable-design.md b/docs/plans/2026-03-21-timetable-design.md new file mode 100644 index 00000000..06194f6c --- /dev/null +++ b/docs/plans/2026-03-21-timetable-design.md @@ -0,0 +1,294 @@ +# TimetableDesign Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the `TimetableDesign` satisfaction model from issue `#511`, register it across the library and CLI, and document the verified worked example in the paper. + +**Architecture:** Implement `TimetableDesign` as a `misc` satisfaction problem whose configuration is a flattened binary tensor `f(c,t,h)` in craftsman-major, task-next, period-last order. Keep this PR scoped to the model and its brute-force-compatible verifier; do not bundle any new reduction rule. Reuse the issue’s verified timetable as the canonical example and make the paper entry read from the same example-db instance so the code, exports, and documentation stay aligned. + +**Tech Stack:** Rust, serde, inventory registry, clap CLI, Typst paper, example-db, `cargo test`, `make test`, `make clippy`, `make paper`. + +--- + +## Inputs Locked From Issue #511 + +- Problem name: `TimetableDesign` +- Category: `src/models/misc/` +- Problem type: `SatisfactionProblem` (`Metric = bool`) +- Core fields: `num_periods`, `num_craftsmen`, `num_tasks`, `craftsman_avail`, `task_avail`, `requirements` +- Complexity string: `"2^(num_craftsmen * num_tasks * num_periods)"` +- Associated rule already exists: issue `#486` (`[Rule] 3SAT to Timetable Design`) +- Solver scope for this PR: brute-force only; no ILP reduction rule in this branch +- Canonical worked example: the 5 craftsmen / 5 tasks / 3 periods YES instance from issue `#511` + +## Representation Decisions + +- Store availability tables as dense boolean matrices: + - `craftsman_avail[c][h]` + - `task_avail[t][h]` + - `requirements[c][t]` +- Flatten the schedule variable `f(c,t,h)` to config index + - `idx = ((c * num_tasks) + t) * num_periods + h` +- `dims()` returns `vec![2; num_craftsmen * num_tasks * num_periods]` +- `evaluate()` must reject: + - wrong config length + - any assignment outside `A(c) ∩ A(t)` + - two tasks for the same craftsman in one period + - two craftsmen on the same task in one period + - any `(c,t)` pair whose assigned periods do not match `R(c,t)` exactly + +## Batch 1: Model, Registration, Example, CLI, Tests + +### Task 1: Write the failing TimetableDesign model tests + +**Files:** +- Create: `src/unit_tests/models/misc/timetable_design.rs` +- Reference: `src/unit_tests/models/misc/resource_constrained_scheduling.rs` +- Reference: `src/unit_tests/models/misc/staff_scheduling.rs` +- Reference: `src/unit_tests/models/formula/sat.rs` + +**Step 1: Write the failing test** + +Add targeted tests for: +- constructor/getter coverage and `dims()` +- a valid timetable instance from a small toy example +- invalid configs for each constraint family +- brute-force solver on a tiny satisfiable instance +- serde round-trip +- issue/paper example validity by checking the provided satisfying config directly (no brute force on the large example) + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test timetable_design --lib +``` + +Expected: compile failure because the `TimetableDesign` model module does not exist yet. + +**Step 3: Commit** + +Do not commit yet. This task intentionally stays red until Task 2. + +### Task 2: Implement the TimetableDesign model and wire it into the crate + +**Files:** +- Create: `src/models/misc/timetable_design.rs` +- Modify: `src/models/misc/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` + +**Step 1: Write minimal implementation** + +Implement: +- `ProblemSchemaEntry` metadata for `TimetableDesign` +- `TimetableDesign::new(...)` with validation that matrix dimensions match the declared counts +- inherent getters: `num_periods()`, `num_craftsmen()`, `num_tasks()`, `craftsman_avail()`, `task_avail()`, `requirements()` +- a private `index(c, t, h)` helper and any tiny decoding helpers needed by tests/example code +- `Problem` + `SatisfactionProblem` impls +- `declare_variants! { default sat TimetableDesign => "2^(num_craftsmen * num_tasks * num_periods)" }` +- `#[cfg(test)]` link to the new unit-test file +- module/export registrations in `src/models/misc/mod.rs`, `src/models/mod.rs`, and `src/lib.rs` prelude/root re-exports + +**Step 2: Run tests to verify green for the model slice** + +Run: + +```bash +cargo test timetable_design --lib +``` + +Expected: the new unit tests pass. + +**Step 3: Commit** + +```bash +git add src/models/misc/timetable_design.rs src/models/misc/mod.rs src/models/mod.rs src/lib.rs src/unit_tests/models/misc/timetable_design.rs +git commit -m "Add TimetableDesign model" +``` + +### Task 3: Register the canonical example and align tests to the issue’s worked timetable + +**Files:** +- Modify: `src/models/misc/timetable_design.rs` +- Modify: `src/models/misc/mod.rs` + +**Step 1: Write the failing example assertions first** + +Extend the model tests to include: +- a helper that builds the exact issue example +- the exact satisfying config from the issue in flattened `(c,t,h)` order +- assertions that `evaluate()` returns `true` +- negative checks produced by flipping one forced assignment or adding a conflicting assignment + +**Step 2: Run the targeted tests to verify they fail for the missing example hookup** + +Run: + +```bash +cargo test timetable_design::tests::test_timetable_design_paper_example_is_valid --lib +``` + +Expected: failure until the canonical example spec exists and the test helper can reuse it cleanly. + +**Step 3: Write minimal implementation** + +Add `canonical_model_example_specs()` in `src/models/misc/timetable_design.rs` using the verified issue instance and its satisfying config, then register it in the `src/models/misc/mod.rs` example chain. + +**Step 4: Run tests to verify green** + +Run: + +```bash +cargo test timetable_design --lib +``` + +Expected: all TimetableDesign model tests pass, including the issue example check. + +**Step 5: Commit** + +```bash +git add src/models/misc/timetable_design.rs src/models/misc/mod.rs src/unit_tests/models/misc/timetable_design.rs +git commit -m "Add TimetableDesign canonical example" +``` + +### Task 4: Add CLI creation support and CLI-level tests + +**Files:** +- Modify: `problemreductions-cli/src/cli.rs` +- Modify: `problemreductions-cli/src/commands/create.rs` + +**Step 1: Write the failing CLI tests first** + +Add tests in `problemreductions-cli/src/commands/create.rs` for: +- `pred create TimetableDesign ...` producing JSON with the expected type and dimensions +- malformed availability/requirement matrices returning user-facing errors instead of panicking +- help-flag naming/hints for any new TimetableDesign-specific flags if the helper tests need updates + +**Step 2: Run the targeted CLI tests to verify red** + +Run: + +```bash +cargo test create_timetable_design --package problemreductions-cli +``` + +Expected: failure because the CLI flags and create-arm do not exist yet. + +**Step 3: Write minimal implementation** + +Add TimetableDesign CLI support: +- new `CreateArgs` fields for `--num-periods`, `--num-craftsmen`, `--num-tasks`, `--craftsman-avail`, `--task-avail` +- reuse `--requirements` with a TimetableDesign-specific matrix parser +- add the problem to the `after_help` “Flags by problem type” table +- add a `"TimetableDesign"` match arm in `create()` with validation and a clear usage string +- add parsing helpers that mirror existing boolean-matrix helpers instead of inventing ad hoc string parsing + +**Step 4: Run tests to verify green** + +Run: + +```bash +cargo test create_timetable_design --package problemreductions-cli +``` + +Expected: the new CLI tests pass. + +**Step 5: Commit** + +```bash +git add problemreductions-cli/src/cli.rs problemreductions-cli/src/commands/create.rs +git commit -m "Add TimetableDesign CLI support" +``` + +### Task 5: Batch-1 verification + +**Files:** +- No new files; verification only + +**Step 1: Run focused verification** + +Run: + +```bash +cargo test timetable_design --lib +cargo test create_timetable_design --package problemreductions-cli +``` + +Expected: both commands pass before starting the paper batch. + +**Step 2: Commit** + +No new commit if the tree is clean. + +## Batch 2: Paper Entry And Final Verification + +### Task 6: Add the paper entry for TimetableDesign + +**Files:** +- Modify: `docs/paper/reductions.typ` + +**Step 1: Write the failing paper check first** + +Run: + +```bash +make paper +``` + +Expected: if the display name or `problem-def("TimetableDesign")` entry is missing, the paper/export checks fail or the model is omitted from the paper coverage. + +**Step 2: Write minimal implementation** + +Add: +- `"TimetableDesign": [Timetable Design]` to the display-name dictionary +- a `#problem-def("TimetableDesign")[...][...]` entry that: + - states the formal definition from issue `#511` + - cites Garey & Johnson / Even-Itai-Shamir + - explains the flattened assignment viewpoint used in the code + - uses the issue’s canonical example and satisfying timetable + - presents the worked schedule in a table or similarly compact visualization appropriate for a 5×5×3 example + +**Step 3: Run the paper build** + +Run: + +```bash +make paper +``` + +Expected: the paper compiles cleanly and includes the new problem entry. + +**Step 4: Commit** + +```bash +git add docs/paper/reductions.typ +git commit -m "Document TimetableDesign in paper" +``` + +### Task 7: Final verification before push + +**Files:** +- No new files; verification only + +**Step 1: Run repo verification** + +Run: + +```bash +make test +make clippy +make paper +git status --short +``` + +Expected: +- `make test` passes +- `make clippy` passes +- `make paper` passes +- `git status --short` shows only intended tracked changes (and no lingering plan artifacts after the implementation phase deletes this file) + +**Step 2: Commit** + +No extra verification-only commit unless a final fix was required. From 93a9b522d6dbad7f016b6b61d52e2c51b7715eea Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 12:53:42 +0800 Subject: [PATCH 2/6] Add TimetableDesign model and CLI support --- problemreductions-cli/src/cli.rs | 18 +- problemreductions-cli/src/commands/create.rs | 252 +++++++++++++++- src/lib.rs | 2 +- src/models/misc/mod.rs | 4 + src/models/misc/timetable_design.rs | 281 ++++++++++++++++++ src/models/mod.rs | 1 + .../models/misc/timetable_design.rs | 167 +++++++++++ 7 files changed, 722 insertions(+), 3 deletions(-) create mode 100644 src/models/misc/timetable_design.rs create mode 100644 src/unit_tests/models/misc/timetable_design.rs diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 5c3bae2d..f03a4cf5 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -274,6 +274,7 @@ Flags by problem type: StrongConnectivityAugmentation --arcs, --candidate-arcs, --bound [--num-vertices] FlowShopScheduling --task-lengths, --deadline [--num-processors] StaffScheduling --schedules, --requirements, --num-workers, --k + TimetableDesign --num-periods, --num-craftsmen, --num-tasks, --craftsman-avail, --task-avail, --requirements MinimumTardinessSequencing --n, --deadlines [--precedence-pairs] RectilinearPictureCompression --matrix (0/1), --k SchedulingWithIndividualDeadlines --n, --num-processors/--m, --deadlines [--precedence-pairs] @@ -551,12 +552,27 @@ pub struct CreateArgs { /// Binary schedule patterns for StaffScheduling (semicolon-separated rows, e.g., "1,1,0;0,1,1") #[arg(long)] pub schedules: Option, - /// Minimum staffing requirements per period for StaffScheduling + /// Requirements for StaffScheduling (comma-separated) or TimetableDesign (semicolon-separated rows) #[arg(long)] pub requirements: Option, /// Number of available workers for StaffScheduling #[arg(long)] pub num_workers: Option, + /// Number of work periods for TimetableDesign + #[arg(long)] + pub num_periods: Option, + /// Number of craftsmen for TimetableDesign + #[arg(long)] + pub num_craftsmen: Option, + /// Number of tasks for TimetableDesign + #[arg(long)] + pub num_tasks: Option, + /// Craftsman availability rows for TimetableDesign (semicolon-separated 0/1 rows) + #[arg(long)] + pub craftsman_avail: Option, + /// Task availability rows for TimetableDesign (semicolon-separated 0/1 rows) + #[arg(long)] + pub task_avail: Option, /// Alphabet size for LCS, SCS, StringToStringCorrection, or TwoDimensionalConsecutiveSets (optional; inferred from the input strings if omitted) #[arg(long)] pub alphabet_size: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 85fb5384..a171d37e 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -24,7 +24,7 @@ use problemreductions::models::misc::{ SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, + StringToStringCorrection, SubsetSum, SumOfSquaresPartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -122,6 +122,11 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.schedules.is_none() && args.requirements.is_none() && args.num_workers.is_none() + && args.num_periods.is_none() + && args.num_craftsmen.is_none() + && args.num_tasks.is_none() + && args.craftsman_avail.is_none() + && args.task_avail.is_none() && args.alphabet_size.is_none() && args.num_groups.is_none() && args.dependencies.is_none() @@ -419,6 +424,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "StaffScheduling" => { "--schedules \"1,1,1,1,1,0,0;0,1,1,1,1,1,0;0,0,1,1,1,1,1;1,0,0,1,1,1,1;1,1,0,0,1,1,1\" --requirements 2,2,2,3,3,2,1 --num-workers 4 --k 5" } + "TimetableDesign" => { + "--num-periods 3 --num-craftsmen 5 --num-tasks 5 --craftsman-avail \"1,1,1;1,1,0;0,1,1;1,0,1;1,1,1\" --task-avail \"1,1,0;0,1,1;1,0,1;1,1,1;1,1,1\" --requirements \"1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0\"" + } "SteinerTree" => "--graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4", "MultipleCopyFileAllocation" => { MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS @@ -507,6 +515,7 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { ("PrimeAttributeName", "query_attribute") => return "query".to_string(), ("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(), ("StaffScheduling", "shifts_per_schedule") => return "k".to_string(), + ("TimetableDesign", "num_tasks") => return "num-tasks".to_string(), _ => {} } // Edge-weight problems use --edge-weights instead of --weights @@ -568,6 +577,10 @@ fn help_flag_hint( ("ShortestCommonSupersequence", "strings") => "symbol lists: \"0,1,2;1,2,0\"", ("MultipleChoiceBranching", "partition") => "semicolon-separated groups: \"0,1;2,3\"", ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", + ("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => { + "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" + } + ("TimetableDesign", "requirements") => "semicolon-separated rows: \"1,0,1;0,1,0\"", _ => type_format_hint(type_name, graph_type), } } @@ -2531,6 +2544,46 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // TimetableDesign + "TimetableDesign" => { + let usage = "Usage: pred create TimetableDesign --num-periods 3 --num-craftsmen 5 --num-tasks 5 --craftsman-avail \"1,1,1;1,1,0;0,1,1;1,0,1;1,1,1\" --task-avail \"1,1,0;0,1,1;1,0,1;1,1,1;1,1,1\" --requirements \"1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0\""; + let num_periods = args.num_periods.ok_or_else(|| { + anyhow::anyhow!("TimetableDesign requires --num-periods\n\n{usage}") + })?; + let num_craftsmen = args.num_craftsmen.ok_or_else(|| { + anyhow::anyhow!("TimetableDesign requires --num-craftsmen\n\n{usage}") + })?; + let num_tasks = args.num_tasks.ok_or_else(|| { + anyhow::anyhow!("TimetableDesign requires --num-tasks\n\n{usage}") + })?; + let craftsman_avail = + parse_named_bool_rows(args.craftsman_avail.as_deref(), "--craftsman-avail", usage)?; + let task_avail = + parse_named_bool_rows(args.task_avail.as_deref(), "--task-avail", usage)?; + let requirements = parse_timetable_requirements(args.requirements.as_deref(), usage)?; + validate_timetable_design_args( + num_periods, + num_craftsmen, + num_tasks, + &craftsman_avail, + &task_avail, + &requirements, + usage, + )?; + + ( + ser(TimetableDesign::new( + num_periods, + num_craftsmen, + num_tasks, + craftsman_avail, + task_avail, + requirements, + ))?, + resolved_variant.clone(), + ) + } + // DirectedTwoCommodityIntegralFlow "DirectedTwoCommodityIntegralFlow" => { let arcs_str = args.arcs.as_deref().ok_or_else(|| { @@ -3950,6 +4003,94 @@ fn validate_staff_scheduling_args( Ok(()) } +fn parse_named_bool_rows(rows: Option<&str>, flag: &str, usage: &str) -> Result>> { + let rows = rows.ok_or_else(|| anyhow::anyhow!("TimetableDesign requires {flag}\n\n{usage}"))?; + parse_bool_rows(rows) +} + +fn parse_timetable_requirements(requirements: Option<&str>, usage: &str) -> Result>> { + let requirements = requirements + .ok_or_else(|| anyhow::anyhow!("TimetableDesign requires --requirements\n\n{usage}"))?; + let matrix: Vec> = requirements + .split(';') + .map(|row| util::parse_comma_list(row.trim())) + .collect::>()?; + + if let Some(expected_width) = matrix.first().map(Vec::len) { + anyhow::ensure!( + matrix.iter().all(|row| row.len() == expected_width), + "All rows in --requirements must have the same length" + ); + } + + Ok(matrix) +} + +fn validate_timetable_design_args( + num_periods: usize, + num_craftsmen: usize, + num_tasks: usize, + craftsman_avail: &[Vec], + task_avail: &[Vec], + requirements: &[Vec], + usage: &str, +) -> Result<()> { + anyhow::ensure!( + craftsman_avail.len() == num_craftsmen, + "craftsman availability row count ({}) must equal num_craftsmen ({})\n\n{}", + craftsman_avail.len(), + num_craftsmen, + usage + ); + anyhow::ensure!( + task_avail.len() == num_tasks, + "task availability row count ({}) must equal num_tasks ({})\n\n{}", + task_avail.len(), + num_tasks, + usage + ); + anyhow::ensure!( + requirements.len() == num_craftsmen, + "requirements row count ({}) must equal num_craftsmen ({})\n\n{}", + requirements.len(), + num_craftsmen, + usage + ); + + for (index, row) in craftsman_avail.iter().enumerate() { + anyhow::ensure!( + row.len() == num_periods, + "craftsman availability row {} has {} periods, expected {}\n\n{}", + index, + row.len(), + num_periods, + usage + ); + } + for (index, row) in task_avail.iter().enumerate() { + anyhow::ensure!( + row.len() == num_periods, + "task availability row {} has {} periods, expected {}\n\n{}", + index, + row.len(), + num_periods, + usage + ); + } + for (index, row) in requirements.iter().enumerate() { + anyhow::ensure!( + row.len() == num_tasks, + "requirements row {} has {} tasks, expected {}\n\n{}", + index, + row.len(), + num_tasks, + usage + ); + } + + Ok(()) +} + /// Parse `--matrix` as semicolon-separated rows of comma-separated f64 values. /// E.g., "1,0.5;0.5,2" fn parse_matrix(args: &CreateArgs) -> Result>> { @@ -4816,6 +4957,110 @@ mod tests { ); } + #[test] + fn test_problem_help_uses_num_tasks_for_timetable_design() { + assert_eq!( + problem_help_flag_name("TimetableDesign", "num_tasks", "usize", false), + "num-tasks" + ); + assert_eq!( + help_flag_hint("TimetableDesign", "craftsman_avail", "Vec>", None), + "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" + ); + } + + #[test] + fn test_create_timetable_design_outputs_problem_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "TimetableDesign", + "--num-periods", + "3", + "--num-craftsmen", + "5", + "--num-tasks", + "5", + "--craftsman-avail", + "1,1,1;1,1,0;0,1,1;1,0,1;1,1,1", + "--task-avail", + "1,1,0;0,1,1;1,0,1;1,1,1;1,1,1", + "--requirements", + "1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0", + ]) + .unwrap(); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let output_path = + std::env::temp_dir().join(format!("timetable-design-create-{suffix}.json")); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&output_path).unwrap()).unwrap(); + assert_eq!(json["type"], "TimetableDesign"); + assert_eq!(json["data"]["num_periods"], 3); + assert_eq!(json["data"]["num_craftsmen"], 5); + assert_eq!(json["data"]["num_tasks"], 5); + std::fs::remove_file(output_path).unwrap(); + } + + #[test] + fn test_create_timetable_design_reports_invalid_matrix_without_panic() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "TimetableDesign", + "--num-periods", + "3", + "--num-craftsmen", + "5", + "--num-tasks", + "5", + "--craftsman-avail", + "1,1,1;1,1", + "--task-avail", + "1,1,0;0,1,1;1,0,1;1,1,1;1,1,1", + "--requirements", + "1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0", + ]) + .unwrap(); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let result = std::panic::catch_unwind(|| create(&args, &out)); + assert!(result.is_ok(), "create should return an error, not panic"); + let err = result.unwrap().unwrap_err().to_string(); + assert!( + err.contains("All rows") || err.contains("craftsman availability row count"), + "expected timetable matrix validation error, got: {err}" + ); + } + #[test] fn test_create_generalized_hex_serializes_problem_json() { let output = temp_output_path("generalized_hex_create"); @@ -4965,6 +5210,11 @@ mod tests { schedules: None, requirements: None, num_workers: None, + num_periods: None, + num_craftsmen: None, + num_tasks: None, + craftsman_avail: None, + task_avail: None, num_groups: None, domain_size: None, relations: None, diff --git a/src/lib.rs b/src/lib.rs index 34ca7f66..a2fdff91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,7 +70,7 @@ pub mod prelude { SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, + StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, TimetableDesign, }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index 37f421cb..b3233305 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -25,6 +25,7 @@ //! - [`SequencingWithReleaseTimesAndDeadlines`]: Single-machine scheduling feasibility //! - [`SequencingWithinIntervals`]: Schedule tasks within time windows //! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length +//! - [`TimetableDesign`]: Schedule craftsmen on tasks across work periods //! - [`StringToStringCorrection`]: String-to-String Correction (derive target via deletions and swaps) //! - [`SubsetSum`]: Find a subset summing to exactly a target value //! - [`SumOfSquaresPartition`]: Partition integers into K groups minimizing sum of squared group sums @@ -57,6 +58,7 @@ mod staff_scheduling; pub(crate) mod string_to_string_correction; mod subset_sum; pub(crate) mod sum_of_squares_partition; +mod timetable_design; pub use additional_key::AdditionalKey; pub use bin_packing::BinPacking; @@ -86,6 +88,7 @@ pub use staff_scheduling::StaffScheduling; pub use string_to_string_correction::StringToStringCorrection; pub use subset_sum::SubsetSum; pub use sum_of_squares_partition::SumOfSquaresPartition; +pub use timetable_design::TimetableDesign; #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { @@ -102,6 +105,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Availability matrix A(c) for craftsmen (|C| x |H|)" }, + FieldInfo { name: "task_avail", type_name: "Vec>", description: "Availability matrix A(t) for tasks (|T| x |H|)" }, + FieldInfo { name: "requirements", type_name: "Vec>", description: "Required work periods R(c,t) for each craftsman-task pair (|C| x |T|)" }, + ], + } +} + +/// The Timetable Design problem. +/// +/// A configuration is a flattened binary tensor `f(c,t,h)` in craftsman-major, +/// task-next, period-last order: +/// `idx = ((c * num_tasks) + t) * num_periods + h`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimetableDesign { + num_periods: usize, + num_craftsmen: usize, + num_tasks: usize, + craftsman_avail: Vec>, + task_avail: Vec>, + requirements: Vec>, +} + +impl TimetableDesign { + /// Create a new Timetable Design instance. + /// + /// # Panics + /// + /// Panics if any matrix dimensions do not match the declared counts. + pub fn new( + num_periods: usize, + num_craftsmen: usize, + num_tasks: usize, + craftsman_avail: Vec>, + task_avail: Vec>, + requirements: Vec>, + ) -> Self { + assert_eq!( + craftsman_avail.len(), + num_craftsmen, + "craftsman_avail has {} rows, expected {}", + craftsman_avail.len(), + num_craftsmen + ); + for (craftsman, row) in craftsman_avail.iter().enumerate() { + assert_eq!( + row.len(), + num_periods, + "craftsman {} availability has {} periods, expected {}", + craftsman, + row.len(), + num_periods + ); + } + + assert_eq!( + task_avail.len(), + num_tasks, + "task_avail has {} rows, expected {}", + task_avail.len(), + num_tasks + ); + for (task, row) in task_avail.iter().enumerate() { + assert_eq!( + row.len(), + num_periods, + "task {} availability has {} periods, expected {}", + task, + row.len(), + num_periods + ); + } + + assert_eq!( + requirements.len(), + num_craftsmen, + "requirements has {} rows, expected {}", + requirements.len(), + num_craftsmen + ); + for (craftsman, row) in requirements.iter().enumerate() { + assert_eq!( + row.len(), + num_tasks, + "requirements row {} has {} tasks, expected {}", + craftsman, + row.len(), + num_tasks + ); + } + + Self { + num_periods, + num_craftsmen, + num_tasks, + craftsman_avail, + task_avail, + requirements, + } + } + + /// Get the number of periods. + pub fn num_periods(&self) -> usize { + self.num_periods + } + + /// Get the number of craftsmen. + pub fn num_craftsmen(&self) -> usize { + self.num_craftsmen + } + + /// Get the number of tasks. + pub fn num_tasks(&self) -> usize { + self.num_tasks + } + + /// Get craftsman availability. + pub fn craftsman_avail(&self) -> &[Vec] { + &self.craftsman_avail + } + + /// Get task availability. + pub fn task_avail(&self) -> &[Vec] { + &self.task_avail + } + + /// Get the pairwise work requirements. + pub fn requirements(&self) -> &[Vec] { + &self.requirements + } + + fn config_len(&self) -> usize { + self.num_craftsmen * self.num_tasks * self.num_periods + } + + fn index(&self, craftsman: usize, task: usize, period: usize) -> usize { + ((craftsman * self.num_tasks) + task) * self.num_periods + period + } +} + +impl Problem for TimetableDesign { + const NAME: &'static str = "TimetableDesign"; + type Metric = bool; + + fn dims(&self) -> Vec { + vec![2; self.config_len()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + if config.len() != self.config_len() { + return false; + } + if config.iter().any(|&value| value > 1) { + return false; + } + + let mut craftsman_busy = vec![vec![false; self.num_periods]; self.num_craftsmen]; + let mut task_busy = vec![vec![false; self.num_periods]; self.num_tasks]; + let mut pair_counts = vec![vec![0u64; self.num_tasks]; self.num_craftsmen]; + + for craftsman in 0..self.num_craftsmen { + for task in 0..self.num_tasks { + for period in 0..self.num_periods { + if config[self.index(craftsman, task, period)] == 0 { + continue; + } + + if !self.craftsman_avail[craftsman][period] || !self.task_avail[task][period] { + return false; + } + + if craftsman_busy[craftsman][period] || task_busy[task][period] { + return false; + } + + craftsman_busy[craftsman][period] = true; + task_busy[task][period] = true; + pair_counts[craftsman][task] += 1; + } + } + } + + pair_counts == self.requirements + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +impl SatisfactionProblem for TimetableDesign {} + +crate::declare_variants! { + default sat TimetableDesign => "2^(num_craftsmen * num_tasks * num_periods)", +} + +#[cfg(any(test, feature = "example-db"))] +const ISSUE_EXAMPLE_ASSIGNMENTS: &[(usize, usize, usize)] = &[ + (0, 0, 0), + (1, 4, 0), + (1, 1, 1), + (2, 3, 1), + (0, 2, 2), + (3, 4, 2), + (4, 1, 2), +]; + +#[cfg(any(test, feature = "example-db"))] +fn issue_example_problem() -> TimetableDesign { + TimetableDesign::new( + 3, + 5, + 5, + vec![ + vec![true, true, true], + vec![true, true, false], + vec![false, true, true], + vec![true, false, true], + vec![true, true, true], + ], + vec![ + vec![true, true, false], + vec![false, true, true], + vec![true, false, true], + vec![true, true, true], + vec![true, true, true], + ], + vec![ + vec![1, 0, 1, 0, 0], + vec![0, 1, 0, 0, 1], + vec![0, 0, 0, 1, 0], + vec![0, 0, 0, 0, 1], + vec![0, 1, 0, 0, 0], + ], + ) +} + +#[cfg(any(test, feature = "example-db"))] +fn issue_example_config() -> Vec { + let problem = issue_example_problem(); + let mut config = vec![0; problem.config_len()]; + for &(craftsman, task, period) in ISSUE_EXAMPLE_ASSIGNMENTS { + config[problem.index(craftsman, task, period)] = 1; + } + config +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "timetable_design", + instance: Box::new(issue_example_problem()), + optimal_config: issue_example_config(), + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/timetable_design.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index a3eb9ef1..c4ac5a20 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -38,6 +38,7 @@ pub use misc::{ SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, StaffScheduling, StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, + TimetableDesign, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs new file mode 100644 index 00000000..7e969b7b --- /dev/null +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -0,0 +1,167 @@ +use crate::models::misc::TimetableDesign; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; + +fn timetable_design_flat_index( + num_tasks: usize, + num_periods: usize, + craftsman: usize, + task: usize, + period: usize, +) -> usize { + ((craftsman * num_tasks) + task) * num_periods + period +} + +fn timetable_design_toy_problem() -> TimetableDesign { + TimetableDesign::new( + 2, + 2, + 2, + vec![vec![true, false], vec![true, true]], + vec![vec![true, true], vec![false, true]], + vec![vec![1, 0], vec![0, 1]], + ) +} + +#[test] +fn test_timetable_design_creation_and_dims() { + let problem = timetable_design_toy_problem(); + + assert_eq!(problem.num_periods(), 2); + assert_eq!(problem.num_craftsmen(), 2); + assert_eq!(problem.num_tasks(), 2); + assert_eq!( + problem.craftsman_avail(), + &[vec![true, false], vec![true, true]] + ); + assert_eq!(problem.task_avail(), &[vec![true, true], vec![false, true]]); + assert_eq!(problem.requirements(), &[vec![1, 0], vec![0, 1]]); + assert_eq!(problem.dims(), vec![2; 8]); +} + +#[test] +fn test_timetable_design_problem_name_and_variant() { + assert_eq!(::NAME, "TimetableDesign"); + assert!(::variant().is_empty()); +} + +#[test] +fn test_timetable_design_evaluate_valid_config() { + let problem = timetable_design_toy_problem(); + let config = vec![1, 0, 0, 0, 0, 0, 0, 1]; + + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_timetable_design_rejects_wrong_config_length() { + let problem = timetable_design_toy_problem(); + + assert!(!problem.evaluate(&[1, 0, 0])); + assert!(!problem.evaluate(&[0; 9])); +} + +#[test] +fn test_timetable_design_rejects_assignment_outside_availability() { + let problem = timetable_design_toy_problem(); + let config = vec![0, 1, 0, 0, 0, 0, 0, 1]; + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_timetable_design_rejects_double_booked_craftsman() { + let problem = timetable_design_toy_problem(); + let config = vec![1, 0, 0, 0, 0, 1, 0, 1]; + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_timetable_design_rejects_double_booked_task() { + let problem = timetable_design_toy_problem(); + let config = vec![1, 0, 0, 0, 1, 0, 0, 1]; + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_timetable_design_rejects_requirement_mismatch() { + let problem = timetable_design_toy_problem(); + let config = vec![1, 0, 0, 0, 0, 0, 0, 0]; + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_timetable_design_bruteforce_solver_finds_solution() { + let problem = timetable_design_toy_problem(); + let solution = BruteForce::new().find_satisfying(&problem); + + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); +} + +#[test] +fn test_timetable_design_serialization_round_trip() { + let problem = timetable_design_toy_problem(); + + let json = serde_json::to_value(&problem).unwrap(); + let restored: TimetableDesign = serde_json::from_value(json).unwrap(); + + assert_eq!(restored.num_periods(), problem.num_periods()); + assert_eq!(restored.num_craftsmen(), problem.num_craftsmen()); + assert_eq!(restored.num_tasks(), problem.num_tasks()); + assert_eq!(restored.craftsman_avail(), problem.craftsman_avail()); + assert_eq!(restored.task_avail(), problem.task_avail()); + assert_eq!(restored.requirements(), problem.requirements()); +} + +#[test] +fn test_timetable_design_issue_example_is_valid() { + let problem = super::issue_example_problem(); + let config = super::issue_example_config(); + + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_timetable_design_issue_example_rejects_flipped_required_assignment() { + let problem = super::issue_example_problem(); + let mut config = super::issue_example_config(); + let forced = timetable_design_flat_index(problem.num_tasks(), problem.num_periods(), 1, 1, 1); + config[forced] = 0; + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_timetable_design_issue_example_rejects_conflicting_assignment() { + let problem = super::issue_example_problem(); + let mut config = super::issue_example_config(); + let conflicting = + timetable_design_flat_index(problem.num_tasks(), problem.num_periods(), 4, 0, 0); + config[conflicting] = 1; + + assert!(!problem.evaluate(&config)); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_timetable_design_paper_example_is_valid() { + let specs = super::canonical_model_example_specs(); + assert_eq!(specs.len(), 1); + + let spec = &specs[0]; + assert_eq!(spec.id, "timetable_design"); + assert_eq!(spec.optimal_config, super::issue_example_config()); + assert_eq!( + spec.instance.serialize_json(), + serde_json::to_value(super::issue_example_problem()).unwrap() + ); + assert_eq!( + spec.instance.evaluate_json(&spec.optimal_config), + serde_json::json!(true) + ); + assert_eq!(spec.optimal_value, serde_json::json!(true)); +} From f25aac40b05d24556b36ba56c7d31a2365f3e599 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 13:11:52 +0800 Subject: [PATCH 3/6] Implement #511: [Model] TimetableDesign --- docs/paper/reductions.typ | 48 ++++++ problemreductions-cli/src/commands/create.rs | 38 ++++- src/models/misc/timetable_design.rs | 137 ++++++++++++++++++ src/solvers/ilp/solver.rs | 8 +- .../models/misc/timetable_design.rs | 58 ++++++++ 5 files changed, 285 insertions(+), 4 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index e7360b02..074177e8 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -143,6 +143,7 @@ "SequencingWithinIntervals": [Sequencing Within Intervals], "ShortestCommonSupersequence": [Shortest Common Supersequence], "StaffScheduling": [Staff Scheduling], + "TimetableDesign": [Timetable Design], "SteinerTree": [Steiner Tree], "SteinerTreeInGraphs": [Steiner Tree in Graphs], "StringToStringCorrection": [String-to-String Correction], @@ -3474,6 +3475,53 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ) ] +#{ + let x = load-model-example("TimetableDesign") + let assignments = x.optimal_config.enumerate().filter(((idx, value)) => value == 1).map(((idx, value)) => ( + calc.floor(idx / (x.instance.num_tasks * x.instance.num_periods)), + calc.floor(calc.rem(idx, x.instance.num_tasks * x.instance.num_periods) / x.instance.num_periods), + calc.rem(idx, x.instance.num_periods), + )) + let fmt-assignment(entry) = $(c_#(entry.at(0) + 1), t_#(entry.at(1) + 1))$ + let period-0 = assignments.filter(entry => entry.at(2) == 0) + let period-1 = assignments.filter(entry => entry.at(2) == 1) + let period-2 = assignments.filter(entry => entry.at(2) == 2) + [ + #problem-def("TimetableDesign")[ + Given a set $H$ of work periods, a set $C$ of craftsmen, a set $T$ of tasks, availability sets $A_C(c) subset.eq H$ for each craftsman $c in C$, availability sets $A_T(t) subset.eq H$ for each task $t in T$, and exact workload requirements $R: C times T -> ZZ_(>= 0)$, determine whether there exists a function $f: C times T times H -> {0, 1}$ such that: + $ + f(c, t, h) = 1 => h in A_C(c) inter A_T(t), + $ + $ + forall c in C, h in H: sum_(t in T) f(c, t, h) <= 1, + $ + $ + forall t in T, h in H: sum_(c in C) f(c, t, h) <= 1, + $ + and + $ + forall c in C, t in T: sum_(h in H) f(c, t, h) = R(c, t). + $ + ][ + Timetable Design is the classical timetabling feasibility problem catalogued as SS19 in Garey & Johnson @garey1979. Even, Itai, and Shamir showed that it is NP-complete even when there are only three work periods, every task is available in every period, and every requirement is binary @evenItaiShamir1976. The same paper also identifies polynomial-time islands, including cases where each craftsman is available in at most two periods or where all craftsmen and tasks are available in every period @evenItaiShamir1976. The implementation in this repository uses one binary variable for each triple $(c, t, h)$, so the registered baseline explores a configuration space of size $2^(|C| |T| |H|)$. + + *Example.* The canonical instance has three periods $H = {h_1, h_2, h_3}$, five craftsmen, five tasks, and seven nonzero workload requirements. The satisfying timetable stored in the example database assigns #period-0.map(fmt-assignment).join(", ") during $h_1$, #period-1.map(fmt-assignment).join(", ") during $h_2$, and #period-2.map(fmt-assignment).join(", ") during $h_3$. Every listed assignment lies in the corresponding availability intersection $A_C(c) inter A_T(t)$, no craftsman or task appears twice in the same period, and each required pair is scheduled exactly once, so the verifier returns YES. + + #figure( + align(center, table( + columns: 2, + align: center, + table.header([Period], [Assignments]), + [$h_1$], [#period-0.map(fmt-assignment).join(", ")], + [$h_2$], [#period-1.map(fmt-assignment).join(", ")], + [$h_3$], [#period-2.map(fmt-assignment).join(", ")], + )), + caption: [Worked Timetable Design instance derived from the canonical example DB. Each row lists the craftsman-task pairs assigned in one work period.], + ) + ] + ] +} + #{ let x = load-model-example("MultiprocessorScheduling") let lengths = x.instance.lengths diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index a171d37e..1215d448 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -4005,7 +4005,10 @@ fn validate_staff_scheduling_args( fn parse_named_bool_rows(rows: Option<&str>, flag: &str, usage: &str) -> Result>> { let rows = rows.ok_or_else(|| anyhow::anyhow!("TimetableDesign requires {flag}\n\n{usage}"))?; - parse_bool_rows(rows) + parse_bool_rows(rows).map_err(|err| { + let message = err.to_string().replace("--matrix", flag); + anyhow::anyhow!("{message}\n\n{usage}") + }) } fn parse_timetable_requirements(requirements: Option<&str>, usage: &str) -> Result>> { @@ -5016,6 +5019,36 @@ mod tests { assert_eq!(json["data"]["num_periods"], 3); assert_eq!(json["data"]["num_craftsmen"], 5); assert_eq!(json["data"]["num_tasks"], 5); + assert_eq!( + json["data"]["craftsman_avail"], + serde_json::json!([ + [true, true, true], + [true, true, false], + [false, true, true], + [true, false, true], + [true, true, true] + ]) + ); + assert_eq!( + json["data"]["task_avail"], + serde_json::json!([ + [true, true, false], + [false, true, true], + [true, false, true], + [true, true, true], + [true, true, true] + ]) + ); + assert_eq!( + json["data"]["requirements"], + serde_json::json!([ + [1, 0, 1, 0, 0], + [0, 1, 0, 0, 1], + [0, 0, 0, 1, 0], + [0, 0, 0, 0, 1], + [0, 1, 0, 0, 0] + ]) + ); std::fs::remove_file(output_path).unwrap(); } @@ -5056,9 +5089,10 @@ mod tests { assert!(result.is_ok(), "create should return an error, not panic"); let err = result.unwrap().unwrap_err().to_string(); assert!( - err.contains("All rows") || err.contains("craftsman availability row count"), + err.contains("--craftsman-avail"), "expected timetable matrix validation error, got: {err}" ); + assert!(err.contains("Usage: pred create TimetableDesign")); } #[test] diff --git a/src/models/misc/timetable_design.rs b/src/models/misc/timetable_design.rs index f4cf350f..2046efb2 100644 --- a/src/models/misc/timetable_design.rs +++ b/src/models/misc/timetable_design.rs @@ -157,6 +157,143 @@ impl TimetableDesign { fn index(&self, craftsman: usize, task: usize, period: usize) -> usize { ((craftsman * self.num_tasks) + task) * self.num_periods + period } + + #[cfg(feature = "ilp-solver")] + pub(crate) fn solve_via_required_assignments(&self) -> Option> { + #[derive(Clone)] + struct PairRequirement { + craftsman: usize, + task: usize, + required: usize, + allowed_periods: Vec, + } + + let mut craftsman_demand = vec![0usize; self.num_craftsmen]; + let mut task_demand = vec![0usize; self.num_tasks]; + let mut pairs = Vec::new(); + + for (craftsman, requirement_row) in self.requirements.iter().enumerate() { + for (task, required_u64) in requirement_row.iter().enumerate() { + let required = usize::try_from(*required_u64).ok()?; + craftsman_demand[craftsman] += required; + task_demand[task] += required; + + if required == 0 { + continue; + } + + let allowed_periods = (0..self.num_periods) + .filter(|&period| { + self.craftsman_avail[craftsman][period] && self.task_avail[task][period] + }) + .collect::>(); + + if allowed_periods.len() < required { + return None; + } + + pairs.push(PairRequirement { + craftsman, + task, + required, + allowed_periods, + }); + } + } + + if craftsman_demand + .iter() + .zip(&self.craftsman_avail) + .any(|(demand, avail)| *demand > avail.iter().filter(|&&v| v).count()) + { + return None; + } + + if task_demand + .iter() + .zip(&self.task_avail) + .any(|(demand, avail)| *demand > avail.iter().filter(|&&v| v).count()) + { + return None; + } + + pairs.sort_by_key(|pair| (pair.allowed_periods.len(), pair.required)); + + struct SearchState<'a> { + problem: &'a TimetableDesign, + pairs: &'a [PairRequirement], + craftsman_busy: Vec>, + task_busy: Vec>, + config: Vec, + } + + impl SearchState<'_> { + fn search_pair(&mut self, pair_index: usize, period_offset: usize, remaining: usize) -> bool { + if pair_index == self.pairs.len() { + return true; + } + + let pair = &self.pairs[pair_index]; + if remaining == 0 { + return self.search_pair( + pair_index + 1, + 0, + self.pairs + .get(pair_index + 1) + .map_or(0, |next| next.required), + ); + } + + let feasible_remaining = pair.allowed_periods[period_offset..] + .iter() + .filter(|&&period| { + !self.craftsman_busy[pair.craftsman][period] + && !self.task_busy[pair.task][period] + }) + .count(); + if feasible_remaining < remaining { + return false; + } + + for candidate_index in period_offset..pair.allowed_periods.len() { + let period = pair.allowed_periods[candidate_index]; + if self.craftsman_busy[pair.craftsman][period] + || self.task_busy[pair.task][period] + { + continue; + } + + self.craftsman_busy[pair.craftsman][period] = true; + self.task_busy[pair.task][period] = true; + self.config[self.problem.index(pair.craftsman, pair.task, period)] = 1; + + if self.search_pair(pair_index, candidate_index + 1, remaining - 1) { + return true; + } + + self.config[self.problem.index(pair.craftsman, pair.task, period)] = 0; + self.task_busy[pair.task][period] = false; + self.craftsman_busy[pair.craftsman][period] = false; + } + + false + } + } + + let mut state = SearchState { + problem: self, + pairs: &pairs, + craftsman_busy: vec![vec![false; self.num_periods]; self.num_craftsmen], + task_busy: vec![vec![false; self.num_periods]; self.num_tasks], + config: vec![0; self.config_len()], + }; + + if state.search_pair(0, 0, pairs.first().map_or(0, |pair| pair.required)) { + Some(state.config) + } else { + None + } + } } impl Problem for TimetableDesign { diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index c7fa333c..a2ba9b98 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -1,6 +1,7 @@ //! ILP solver implementation using HiGHS. use crate::models::algebraic::{Comparison, ObjectiveSense, VariableDomain, ILP}; +use crate::models::misc::TimetableDesign; use crate::rules::{ReduceTo, ReductionResult}; #[cfg(not(feature = "ilp-highs"))] use good_lp::default_solver; @@ -171,9 +172,9 @@ impl ILPSolver { Some(reduction.extract_solution(&ilp_solution)) } - /// Solve a type-erased ILP instance (`ILP` or `ILP`). + /// Solve a type-erased problem directly when a native solver hook exists. /// - /// Returns `None` if the input is not an ILP type or if the solver finds no solution. + /// Returns `None` if the input type has no direct solver or the solver finds no solution. pub fn solve_dyn(&self, any: &dyn std::any::Any) -> Option> { if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); @@ -181,6 +182,9 @@ impl ILPSolver { if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); } + if let Some(problem) = any.downcast_ref::() { + return problem.solve_via_required_assignments(); + } None } diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs index 7e969b7b..5fbcb687 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -1,6 +1,8 @@ use crate::models::misc::TimetableDesign; use crate::solvers::{BruteForce, Solver}; use crate::traits::Problem; +#[cfg(feature = "ilp-solver")] +use std::collections::BTreeMap; fn timetable_design_flat_index( num_tasks: usize, @@ -45,6 +47,32 @@ fn test_timetable_design_problem_name_and_variant() { assert!(::variant().is_empty()); } +#[test] +#[should_panic(expected = "craftsman_avail has 1 rows, expected 2")] +fn test_timetable_design_new_panics_on_craftsman_row_count_mismatch() { + let _ = TimetableDesign::new( + 2, + 2, + 2, + vec![vec![true, false]], + vec![vec![true, true], vec![false, true]], + vec![vec![1, 0], vec![0, 1]], + ); +} + +#[test] +#[should_panic(expected = "requirements row 0 has 1 tasks, expected 2")] +fn test_timetable_design_new_panics_on_requirement_width_mismatch() { + let _ = TimetableDesign::new( + 2, + 2, + 2, + vec![vec![true, false], vec![true, true]], + vec![vec![true, true], vec![false, true]], + vec![vec![1], vec![0, 1]], + ); +} + #[test] fn test_timetable_design_evaluate_valid_config() { let problem = timetable_design_toy_problem(); @@ -102,6 +130,36 @@ fn test_timetable_design_bruteforce_solver_finds_solution() { assert!(problem.evaluate(&solution.unwrap())); } +#[cfg(feature = "ilp-solver")] +#[test] +fn test_timetable_design_issue_example_is_solved_via_ilp_solver_dispatch() { + let problem = super::issue_example_problem(); + let solution = crate::solvers::ILPSolver::new() + .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) + .expect("expected ILP solver dispatch to find a satisfying timetable"); + + assert!(problem.evaluate(&solution)); +} + +#[cfg(feature = "ilp-solver")] +#[test] +fn test_timetable_design_unsat_instance_returns_none_via_ilp_solver_dispatch() { + let problem = TimetableDesign::new( + 1, + 2, + 1, + vec![vec![true], vec![true]], + vec![vec![true]], + vec![vec![1], vec![1]], + ); + + assert!( + crate::solvers::ILPSolver::new() + .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) + .is_none() + ); +} + #[test] fn test_timetable_design_serialization_round_trip() { let problem = timetable_design_toy_problem(); From c7df7c186bdc5588a84cbcd5169f6562bf54fd9f Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 13:11:58 +0800 Subject: [PATCH 4/6] chore: remove plan file after implementation --- docs/plans/2026-03-21-timetable-design.md | 294 ---------------------- 1 file changed, 294 deletions(-) delete mode 100644 docs/plans/2026-03-21-timetable-design.md diff --git a/docs/plans/2026-03-21-timetable-design.md b/docs/plans/2026-03-21-timetable-design.md deleted file mode 100644 index 06194f6c..00000000 --- a/docs/plans/2026-03-21-timetable-design.md +++ /dev/null @@ -1,294 +0,0 @@ -# TimetableDesign Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add the `TimetableDesign` satisfaction model from issue `#511`, register it across the library and CLI, and document the verified worked example in the paper. - -**Architecture:** Implement `TimetableDesign` as a `misc` satisfaction problem whose configuration is a flattened binary tensor `f(c,t,h)` in craftsman-major, task-next, period-last order. Keep this PR scoped to the model and its brute-force-compatible verifier; do not bundle any new reduction rule. Reuse the issue’s verified timetable as the canonical example and make the paper entry read from the same example-db instance so the code, exports, and documentation stay aligned. - -**Tech Stack:** Rust, serde, inventory registry, clap CLI, Typst paper, example-db, `cargo test`, `make test`, `make clippy`, `make paper`. - ---- - -## Inputs Locked From Issue #511 - -- Problem name: `TimetableDesign` -- Category: `src/models/misc/` -- Problem type: `SatisfactionProblem` (`Metric = bool`) -- Core fields: `num_periods`, `num_craftsmen`, `num_tasks`, `craftsman_avail`, `task_avail`, `requirements` -- Complexity string: `"2^(num_craftsmen * num_tasks * num_periods)"` -- Associated rule already exists: issue `#486` (`[Rule] 3SAT to Timetable Design`) -- Solver scope for this PR: brute-force only; no ILP reduction rule in this branch -- Canonical worked example: the 5 craftsmen / 5 tasks / 3 periods YES instance from issue `#511` - -## Representation Decisions - -- Store availability tables as dense boolean matrices: - - `craftsman_avail[c][h]` - - `task_avail[t][h]` - - `requirements[c][t]` -- Flatten the schedule variable `f(c,t,h)` to config index - - `idx = ((c * num_tasks) + t) * num_periods + h` -- `dims()` returns `vec![2; num_craftsmen * num_tasks * num_periods]` -- `evaluate()` must reject: - - wrong config length - - any assignment outside `A(c) ∩ A(t)` - - two tasks for the same craftsman in one period - - two craftsmen on the same task in one period - - any `(c,t)` pair whose assigned periods do not match `R(c,t)` exactly - -## Batch 1: Model, Registration, Example, CLI, Tests - -### Task 1: Write the failing TimetableDesign model tests - -**Files:** -- Create: `src/unit_tests/models/misc/timetable_design.rs` -- Reference: `src/unit_tests/models/misc/resource_constrained_scheduling.rs` -- Reference: `src/unit_tests/models/misc/staff_scheduling.rs` -- Reference: `src/unit_tests/models/formula/sat.rs` - -**Step 1: Write the failing test** - -Add targeted tests for: -- constructor/getter coverage and `dims()` -- a valid timetable instance from a small toy example -- invalid configs for each constraint family -- brute-force solver on a tiny satisfiable instance -- serde round-trip -- issue/paper example validity by checking the provided satisfying config directly (no brute force on the large example) - -**Step 2: Run test to verify it fails** - -Run: - -```bash -cargo test timetable_design --lib -``` - -Expected: compile failure because the `TimetableDesign` model module does not exist yet. - -**Step 3: Commit** - -Do not commit yet. This task intentionally stays red until Task 2. - -### Task 2: Implement the TimetableDesign model and wire it into the crate - -**Files:** -- Create: `src/models/misc/timetable_design.rs` -- Modify: `src/models/misc/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` - -**Step 1: Write minimal implementation** - -Implement: -- `ProblemSchemaEntry` metadata for `TimetableDesign` -- `TimetableDesign::new(...)` with validation that matrix dimensions match the declared counts -- inherent getters: `num_periods()`, `num_craftsmen()`, `num_tasks()`, `craftsman_avail()`, `task_avail()`, `requirements()` -- a private `index(c, t, h)` helper and any tiny decoding helpers needed by tests/example code -- `Problem` + `SatisfactionProblem` impls -- `declare_variants! { default sat TimetableDesign => "2^(num_craftsmen * num_tasks * num_periods)" }` -- `#[cfg(test)]` link to the new unit-test file -- module/export registrations in `src/models/misc/mod.rs`, `src/models/mod.rs`, and `src/lib.rs` prelude/root re-exports - -**Step 2: Run tests to verify green for the model slice** - -Run: - -```bash -cargo test timetable_design --lib -``` - -Expected: the new unit tests pass. - -**Step 3: Commit** - -```bash -git add src/models/misc/timetable_design.rs src/models/misc/mod.rs src/models/mod.rs src/lib.rs src/unit_tests/models/misc/timetable_design.rs -git commit -m "Add TimetableDesign model" -``` - -### Task 3: Register the canonical example and align tests to the issue’s worked timetable - -**Files:** -- Modify: `src/models/misc/timetable_design.rs` -- Modify: `src/models/misc/mod.rs` - -**Step 1: Write the failing example assertions first** - -Extend the model tests to include: -- a helper that builds the exact issue example -- the exact satisfying config from the issue in flattened `(c,t,h)` order -- assertions that `evaluate()` returns `true` -- negative checks produced by flipping one forced assignment or adding a conflicting assignment - -**Step 2: Run the targeted tests to verify they fail for the missing example hookup** - -Run: - -```bash -cargo test timetable_design::tests::test_timetable_design_paper_example_is_valid --lib -``` - -Expected: failure until the canonical example spec exists and the test helper can reuse it cleanly. - -**Step 3: Write minimal implementation** - -Add `canonical_model_example_specs()` in `src/models/misc/timetable_design.rs` using the verified issue instance and its satisfying config, then register it in the `src/models/misc/mod.rs` example chain. - -**Step 4: Run tests to verify green** - -Run: - -```bash -cargo test timetable_design --lib -``` - -Expected: all TimetableDesign model tests pass, including the issue example check. - -**Step 5: Commit** - -```bash -git add src/models/misc/timetable_design.rs src/models/misc/mod.rs src/unit_tests/models/misc/timetable_design.rs -git commit -m "Add TimetableDesign canonical example" -``` - -### Task 4: Add CLI creation support and CLI-level tests - -**Files:** -- Modify: `problemreductions-cli/src/cli.rs` -- Modify: `problemreductions-cli/src/commands/create.rs` - -**Step 1: Write the failing CLI tests first** - -Add tests in `problemreductions-cli/src/commands/create.rs` for: -- `pred create TimetableDesign ...` producing JSON with the expected type and dimensions -- malformed availability/requirement matrices returning user-facing errors instead of panicking -- help-flag naming/hints for any new TimetableDesign-specific flags if the helper tests need updates - -**Step 2: Run the targeted CLI tests to verify red** - -Run: - -```bash -cargo test create_timetable_design --package problemreductions-cli -``` - -Expected: failure because the CLI flags and create-arm do not exist yet. - -**Step 3: Write minimal implementation** - -Add TimetableDesign CLI support: -- new `CreateArgs` fields for `--num-periods`, `--num-craftsmen`, `--num-tasks`, `--craftsman-avail`, `--task-avail` -- reuse `--requirements` with a TimetableDesign-specific matrix parser -- add the problem to the `after_help` “Flags by problem type” table -- add a `"TimetableDesign"` match arm in `create()` with validation and a clear usage string -- add parsing helpers that mirror existing boolean-matrix helpers instead of inventing ad hoc string parsing - -**Step 4: Run tests to verify green** - -Run: - -```bash -cargo test create_timetable_design --package problemreductions-cli -``` - -Expected: the new CLI tests pass. - -**Step 5: Commit** - -```bash -git add problemreductions-cli/src/cli.rs problemreductions-cli/src/commands/create.rs -git commit -m "Add TimetableDesign CLI support" -``` - -### Task 5: Batch-1 verification - -**Files:** -- No new files; verification only - -**Step 1: Run focused verification** - -Run: - -```bash -cargo test timetable_design --lib -cargo test create_timetable_design --package problemreductions-cli -``` - -Expected: both commands pass before starting the paper batch. - -**Step 2: Commit** - -No new commit if the tree is clean. - -## Batch 2: Paper Entry And Final Verification - -### Task 6: Add the paper entry for TimetableDesign - -**Files:** -- Modify: `docs/paper/reductions.typ` - -**Step 1: Write the failing paper check first** - -Run: - -```bash -make paper -``` - -Expected: if the display name or `problem-def("TimetableDesign")` entry is missing, the paper/export checks fail or the model is omitted from the paper coverage. - -**Step 2: Write minimal implementation** - -Add: -- `"TimetableDesign": [Timetable Design]` to the display-name dictionary -- a `#problem-def("TimetableDesign")[...][...]` entry that: - - states the formal definition from issue `#511` - - cites Garey & Johnson / Even-Itai-Shamir - - explains the flattened assignment viewpoint used in the code - - uses the issue’s canonical example and satisfying timetable - - presents the worked schedule in a table or similarly compact visualization appropriate for a 5×5×3 example - -**Step 3: Run the paper build** - -Run: - -```bash -make paper -``` - -Expected: the paper compiles cleanly and includes the new problem entry. - -**Step 4: Commit** - -```bash -git add docs/paper/reductions.typ -git commit -m "Document TimetableDesign in paper" -``` - -### Task 7: Final verification before push - -**Files:** -- No new files; verification only - -**Step 1: Run repo verification** - -Run: - -```bash -make test -make clippy -make paper -git status --short -``` - -Expected: -- `make test` passes -- `make clippy` passes -- `make paper` passes -- `git status --short` shows only intended tracked changes (and no lingering plan artifacts after the implementation phase deletes this file) - -**Step 2: Commit** - -No extra verification-only commit unless a final fix was required. From a0c876aeac2f1e0b06b3b13b842f6c7de5b760de Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 14:45:13 +0800 Subject: [PATCH 5/6] Fix formatting after merge with main --- src/models/misc/timetable_design.rs | 7 ++++++- src/unit_tests/models/misc/timetable_design.rs | 8 +++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/models/misc/timetable_design.rs b/src/models/misc/timetable_design.rs index 2046efb2..f7678d9f 100644 --- a/src/models/misc/timetable_design.rs +++ b/src/models/misc/timetable_design.rs @@ -228,7 +228,12 @@ impl TimetableDesign { } impl SearchState<'_> { - fn search_pair(&mut self, pair_index: usize, period_offset: usize, remaining: usize) -> bool { + fn search_pair( + &mut self, + pair_index: usize, + period_offset: usize, + remaining: usize, + ) -> bool { if pair_index == self.pairs.len() { return true; } diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs index 5fbcb687..01f90793 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -153,11 +153,9 @@ fn test_timetable_design_unsat_instance_returns_none_via_ilp_solver_dispatch() { vec![vec![1], vec![1]], ); - assert!( - crate::solvers::ILPSolver::new() - .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) - .is_none() - ); + assert!(crate::solvers::ILPSolver::new() + .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) + .is_none()); } #[test] From abf52dcfc9c135e366ff0f7049ae7dca35fe66d4 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 14:53:29 +0800 Subject: [PATCH 6/6] Fix display-name alphabetical ordering for TimetableDesign --- docs/paper/reductions.typ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 3f5feade..44e452b7 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -145,13 +145,13 @@ "SequencingWithinIntervals": [Sequencing Within Intervals], "ShortestCommonSupersequence": [Shortest Common Supersequence], "StaffScheduling": [Staff Scheduling], - "TimetableDesign": [Timetable Design], "SteinerTree": [Steiner Tree], "SteinerTreeInGraphs": [Steiner Tree in Graphs], "StringToStringCorrection": [String-to-String Correction], "StrongConnectivityAugmentation": [Strong Connectivity Augmentation], "SubgraphIsomorphism": [Subgraph Isomorphism], "SumOfSquaresPartition": [Sum of Squares Partition], + "TimetableDesign": [Timetable Design], "TwoDimensionalConsecutiveSets": [2-Dimensional Consecutive Sets], )