diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a87476d43..44e452b7a 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -151,6 +151,7 @@ "StrongConnectivityAugmentation": [Strong Connectivity Augmentation], "SubgraphIsomorphism": [Subgraph Isomorphism], "SumOfSquaresPartition": [Sum of Squares Partition], + "TimetableDesign": [Timetable Design], "TwoDimensionalConsecutiveSets": [2-Dimensional Consecutive Sets], ) @@ -3531,6 +3532,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/cli.rs b/problemreductions-cli/src/cli.rs index e2bca054e..902688bf4 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -276,6 +276,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] @@ -568,12 +569,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 878ee9bac..687d7d2c7 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::*; @@ -127,6 +127,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() @@ -439,6 +444,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 @@ -527,6 +535,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 @@ -588,6 +597,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), } } @@ -2615,6 +2628,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(|| { @@ -4108,6 +4161,97 @@ 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).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>> { + 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>> { @@ -4974,6 +5118,141 @@ 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); + 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(); + } + + #[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("--craftsman-avail"), + "expected timetable matrix validation error, got: {err}" + ); + assert!(err.contains("Usage: pred create TimetableDesign")); + } + #[test] fn test_create_generalized_hex_serializes_problem_json() { let output = temp_output_path("generalized_hex_create"); @@ -5128,6 +5407,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 1c051bb98..5d7b1d942 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,7 +71,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 37f421cb3..b3233305e 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 + } + + #[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 { + 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 61f21e9f8..4c41863dc 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -39,6 +39,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/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index c7fa333c2..a2ba9b985 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 new file mode 100644 index 000000000..01f907933 --- /dev/null +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -0,0 +1,223 @@ +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, + 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] +#[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(); + 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())); +} + +#[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(); + + 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)); +}