diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index fe69cb2d9..141a7fe42 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -145,6 +145,7 @@ "ExactCoverBy3Sets": [Exact Cover by 3-Sets], "SubsetSum": [Subset Sum], "Partition": [Partition], + "ThreePartition": [3-Partition], "PartialFeedbackEdgeSet": [Partial Feedback Edge Set], "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], @@ -4587,6 +4588,43 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], *Example.* Let $A = {5, 3, 8, 2, 7, 1}$ ($n = 6$) and $K = 3$ groups. The partition $A_1 = {8, 1}$, $A_2 = {5, 2}$, $A_3 = {3, 7}$ gives group sums $9, 7, 10$ and sum of squares $81 + 49 + 100 = 230$. The optimal partition has group sums ${9, 9, 8}$ yielding $81 + 81 + 64 = 226$. ] +#{ + let x = load-model-example("ThreePartition") + let sizes = x.instance.sizes + let bound = x.instance.bound + let config = x.optimal_config + let m = int(sizes.len() / 3) + // Group elements by their assignment in optimal_config + let groups = range(m).map(g => { + let indices = range(sizes.len()).filter(i => config.at(i) == g) + indices.map(i => sizes.at(i)) + }) + [ + #problem-def("ThreePartition")[ + Given a set $A = {a_0, dots, a_(3m-1)}$ of $3m$ elements, a bound $B in ZZ^+$, and sizes $s(a) in ZZ^+$ such that $B/4 lt s(a) lt B/2$ for every $a in A$ and $sum_(a in A) s(a) = m B$, determine whether $A$ can be partitioned into $m$ disjoint triples $A_1, dots, A_m$ with $sum_(a in A_i) s(a) = B$ for every $i$. + ][ + 3-Partition is Garey and Johnson's strongly NP-complete benchmark SP15 @garey1979. Unlike ordinary Partition, the strict size window forces every feasible block to contain exactly three elements, making the problem the canonical source for strong NP-completeness reductions to scheduling, packing, and layout models. The implementation in this repository uses one group-assignment variable per element, so the exported exact-search baseline is $O^*(3^n)$#footnote[This is the direct worst-case bound induced by the implementation's configuration space and matches the registered catalog expression `3^num_elements`; no sharper general exact bound was independently verified while preparing this entry.]. + + *Example.* Let $B = #bound$ and consider the #(sizes.len())-element instance with sizes $(#sizes.map(str).join(", "))$. The witness triples #groups.enumerate().map(((i, g)) => [$A_#(i+1) = {#g.map(str).join(", ")}$]).join([ and ]) both sum to $#bound$, so this instance is satisfiable. + + #pred-commands( + "pred create --example ThreePartition -o three-partition.json", + "pred solve three-partition.json", + "pred evaluate three-partition.json --config " + config.map(str).join(","), + ) + + #align(center, table( + columns: 3, + align: center, + table.header([Triple], [Elements], [Sum]), + ..groups.enumerate().map(((i, g)) => ( + [$A_#(i+1)$], [$#(g.map(str).join(", "))$], [$#bound$], + )).flatten(), + )) + ] + ] +} + #{ let x = load-model-example("SequencingWithReleaseTimesAndDeadlines") let n = x.instance.lengths.len() diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 95781bc02..0721f3058 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -247,6 +247,7 @@ Flags by problem type: BinPacking --sizes, --capacity CapacityAssignment --capacities, --cost-matrix, --delay-matrix, --delay-budget SubsetSum --sizes, --target + ThreePartition --sizes, --bound SumOfSquaresPartition --sizes, --num-groups ExpectedRetrievalCost --probabilities, --num-sectors PaintShop --sequence diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 125284030..efc099f4b 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -29,7 +29,7 @@ use problemreductions::models::misc::{ SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, - SumOfSquaresPartition, TimetableDesign, + SumOfSquaresPartition, ThreePartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -668,6 +668,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--sizes 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" } "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", + "ThreePartition" => "--sizes 4,5,6,4,6,5 --bound 15", "BoyceCoddNormalFormViolation" => { "--n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" } @@ -2288,6 +2289,33 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // ThreePartition + "ThreePartition" => { + let sizes_str = args.sizes.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "ThreePartition requires --sizes and --bound\n\n\ + Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "ThreePartition requires --bound\n\n\ + Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" + ) + })?; + let bound = u64::try_from(bound).map_err(|_| { + anyhow::anyhow!( + "ThreePartition requires a positive integer --bound\n\n\ + Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" + ) + })?; + let sizes: Vec = util::parse_comma_list(sizes_str)?; + ( + ser(ThreePartition::try_new(sizes, bound).map_err(anyhow::Error::msg)?)?, + resolved_variant.clone(), + ) + } + // SumOfSquaresPartition "SumOfSquaresPartition" => { let sizes_str = args.sizes.as_deref().ok_or_else(|| { @@ -6501,6 +6529,104 @@ mod tests { assert!(example.contains("--requirement")); } + #[test] + fn test_example_for_three_partition_mentions_sizes_and_bound() { + let example = example_for("ThreePartition", None); + assert!(example.contains("--sizes")); + assert!(example.contains("--bound")); + } + + #[test] + fn test_create_three_partition_outputs_problem_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "ThreePartition", + "--sizes", + "4,5,6,4,6,5", + "--bound", + "15", + ]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let output_path = temp_output_path("three_partition_create"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).expect("create ThreePartition JSON"); + + let created: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); + fs::remove_file(output_path).ok(); + + assert_eq!(created["type"], "ThreePartition"); + assert_eq!( + created["data"]["sizes"], + serde_json::json!([4, 5, 6, 4, 6, 5]) + ); + assert_eq!(created["data"]["bound"], 15); + } + + #[test] + fn test_create_three_partition_requires_bound() { + let cli = + Cli::try_parse_from(["pred", "create", "ThreePartition", "--sizes", "4,5,6,4,6,5"]) + .expect("parse create command"); + + 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 err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("ThreePartition requires --bound")); + } + + #[test] + fn test_create_three_partition_rejects_invalid_instance() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "ThreePartition", + "--sizes", + "4,5,6,4,6,5", + "--bound", + "14", + ]) + .expect("parse create command"); + + 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 err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("must equal m * bound")); + } + #[test] fn test_create_timetable_design_outputs_problem_json() { let cli = Cli::try_parse_from([ diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 94817e754..c449dac47 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -281,6 +281,8 @@ mod tests { assert_eq!(resolve_alias("MVC"), "MinimumVertexCover"); assert_eq!(resolve_alias("SAT"), "Satisfiability"); assert_eq!(resolve_alias("X3C"), "ExactCoverBy3Sets"); + assert_eq!(resolve_alias("3Partition"), "ThreePartition"); + assert_eq!(resolve_alias("3-partition"), "ThreePartition"); // 3SAT is no longer a registered alias (removed to avoid confusion with KSatisfiability/KN) assert_eq!(resolve_alias("3SAT"), "3SAT"); // pass-through assert_eq!(resolve_alias("QUBO"), "QUBO"); diff --git a/src/lib.rs b/src/lib.rs index 4a4dfae7a..7193925fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,7 +78,8 @@ pub mod prelude { SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, TimetableDesign, + StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, ThreePartition, + 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 59fcc78d0..dd17c5bb7 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -68,6 +68,7 @@ mod staff_scheduling; pub(crate) mod string_to_string_correction; mod subset_sum; pub(crate) mod sum_of_squares_partition; +mod three_partition; mod timetable_design; pub use additional_key::AdditionalKey; @@ -106,6 +107,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 three_partition::ThreePartition; pub use timetable_design::TimetableDesign; #[cfg(feature = "example-db")] @@ -146,5 +148,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Positive integer sizes s(a) for each element a in A" }, + FieldInfo { name: "bound", type_name: "u64", description: "Target sum B for each triple" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "ThreePartition", + fields: &["num_elements", "num_groups"], + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ThreePartition { + sizes: Vec, + bound: u64, +} + +impl ThreePartition { + fn validate_inputs(sizes: &[u64], bound: u64) -> Result<(), String> { + if sizes.is_empty() { + return Err("ThreePartition requires at least one element".to_string()); + } + if !sizes.len().is_multiple_of(3) { + return Err( + "ThreePartition requires the number of elements to be a multiple of 3".to_string(), + ); + } + if bound == 0 { + return Err("ThreePartition requires a positive bound".to_string()); + } + if sizes.contains(&0) { + return Err("All sizes must be positive (> 0)".to_string()); + } + + let bound128 = u128::from(bound); + for &size in sizes { + let size = u128::from(size); + if !(4 * size > bound128 && 2 * size < bound128) { + return Err("Every size must lie strictly between B/4 and B/2".to_string()); + } + } + + let total_sum: u128 = sizes.iter().map(|&size| u128::from(size)).sum(); + let expected_sum = u128::from(bound) * (sizes.len() as u128 / 3); + if total_sum != expected_sum { + return Err("Total sum of sizes must equal m * bound".to_string()); + } + if total_sum > u128::from(u64::MAX) { + return Err("Total sum exceeds u64 range".to_string()); + } + + Ok(()) + } + + pub fn try_new(sizes: Vec, bound: u64) -> Result { + Self::validate_inputs(&sizes, bound)?; + Ok(Self { sizes, bound }) + } + + /// Create a new 3-Partition instance. + /// + /// # Panics + /// + /// Panics if the input violates the classical 3-Partition invariants. + pub fn new(sizes: Vec, bound: u64) -> Self { + Self::try_new(sizes, bound).unwrap_or_else(|message| panic!("{message}")) + } + + pub fn sizes(&self) -> &[u64] { + &self.sizes + } + + pub fn bound(&self) -> u64 { + self.bound + } + + pub fn num_elements(&self) -> usize { + self.sizes.len() + } + + pub fn num_groups(&self) -> usize { + self.sizes.len() / 3 + } + + pub fn total_sum(&self) -> u64 { + self.sizes + .iter() + .copied() + .reduce(|acc, value| { + acc.checked_add(value) + .expect("validated sum must fit in u64") + }) + .unwrap_or(0) + } + + fn group_counts_and_sums(&self, config: &[usize]) -> Option<(Vec, Vec)> { + if config.len() != self.num_elements() { + return None; + } + + let mut counts = vec![0usize; self.num_groups()]; + let mut sums = vec![0u128; self.num_groups()]; + + for (index, &group) in config.iter().enumerate() { + if group >= self.num_groups() { + return None; + } + counts[group] += 1; + sums[group] += u128::from(self.sizes[index]); + } + + Some((counts, sums)) + } +} + +#[derive(Deserialize)] +struct ThreePartitionData { + sizes: Vec, + bound: u64, +} + +impl<'de> Deserialize<'de> for ThreePartition { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let data = ThreePartitionData::deserialize(deserializer)?; + Self::try_new(data.sizes, data.bound).map_err(D::Error::custom) + } +} + +impl Problem for ThreePartition { + const NAME: &'static str = "ThreePartition"; + type Value = Or; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![self.num_groups(); self.num_elements()] + } + + fn evaluate(&self, config: &[usize]) -> Or { + Or({ + let Some((counts, sums)) = self.group_counts_and_sums(config) else { + return Or(false); + }; + + let target = u128::from(self.bound); + counts.into_iter().all(|count| count == 3) && sums.into_iter().all(|sum| sum == target) + }) + } +} + +crate::declare_variants! { + default ThreePartition => "3^num_elements", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "three_partition", + instance: Box::new(ThreePartition::new(vec![4, 5, 6, 4, 6, 5], 15)), + optimal_config: vec![0, 0, 0, 1, 1, 1], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/three_partition.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 591aa8640..dbace3877 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -45,7 +45,8 @@ pub use misc::{ SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, TimetableDesign, + StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, ThreePartition, + TimetableDesign, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/unit_tests/models/misc/three_partition.rs b/src/unit_tests/models/misc/three_partition.rs new file mode 100644 index 000000000..af70099a1 --- /dev/null +++ b/src/unit_tests/models/misc/three_partition.rs @@ -0,0 +1,147 @@ +use crate::models::misc::ThreePartition; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Or; + +fn yes_problem() -> ThreePartition { + ThreePartition::new(vec![4, 5, 6, 4, 6, 5], 15) +} + +#[test] +fn test_three_partition_basic() { + let problem = yes_problem(); + assert_eq!(problem.sizes(), &[4, 5, 6, 4, 6, 5]); + assert_eq!(problem.bound(), 15); + assert_eq!(problem.num_elements(), 6); + assert_eq!(problem.num_groups(), 2); + assert_eq!(problem.total_sum(), 30); + assert_eq!(problem.dims(), vec![2; 6]); + assert_eq!(problem.num_variables(), 6); + assert_eq!(::NAME, "ThreePartition"); + assert_eq!(::variant(), vec![]); +} + +#[test] +fn test_three_partition_evaluate_yes_instance() { + let problem = yes_problem(); + assert_eq!(problem.evaluate(&[0, 0, 0, 1, 1, 1]), Or(true)); +} + +#[test] +fn test_three_partition_rejects_wrong_group_sizes_or_sums() { + let problem = yes_problem(); + assert_eq!(problem.evaluate(&[0, 0, 1, 1, 1, 1]), Or(false)); + assert_eq!(problem.evaluate(&[0, 1, 0, 1, 0, 1]), Or(false)); +} + +#[test] +fn test_three_partition_rejects_invalid_configs() { + let problem = yes_problem(); + assert_eq!(problem.evaluate(&[0, 0, 0]), Or(false)); + assert_eq!(problem.evaluate(&[0, 0, 0, 1, 1, 1, 0]), Or(false)); + assert_eq!(problem.evaluate(&[0, 0, 0, 1, 1, 2]), Or(false)); +} + +#[test] +fn test_three_partition_solver_finds_witness() { + let problem = yes_problem(); + let solver = BruteForce::new(); + let solution = solver.find_witness(&problem).unwrap(); + assert_eq!(problem.evaluate(&solution), Or(true)); +} + +#[test] +fn test_three_partition_solver_reports_unsatisfiable_instance() { + let problem = ThreePartition::new(vec![6, 6, 6, 6, 7, 9], 20); + let solver = BruteForce::new(); + assert!(solver.find_witness(&problem).is_none()); +} + +#[test] +fn test_three_partition_paper_example() { + let problem = yes_problem(); + let config = vec![0, 0, 0, 1, 1, 1]; + assert_eq!(problem.evaluate(&config), Or(true)); + + let solver = BruteForce::new(); + let all = solver.find_all_witnesses(&problem); + assert_eq!(all.len(), 8); + assert!(all.iter().all(|sol| problem.evaluate(sol) == Or(true))); +} + +#[test] +fn test_three_partition_serialization_round_trip() { + let problem = yes_problem(); + let json = serde_json::to_value(&problem).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "sizes": [4, 5, 6, 4, 6, 5], + "bound": 15, + }) + ); + + let restored: ThreePartition = serde_json::from_value(json).unwrap(); + assert_eq!(restored.sizes(), problem.sizes()); + assert_eq!(restored.bound(), problem.bound()); +} + +#[test] +fn test_three_partition_deserialization_rejects_invalid_instances() { + let invalid_cases = [ + serde_json::json!({ + "sizes": [], + "bound": 15, + }), + serde_json::json!({ + "sizes": [4, 5, 6, 4, 6], + "bound": 15, + }), + serde_json::json!({ + "sizes": [4, 5, 0, 4, 6, 5], + "bound": 15, + }), + serde_json::json!({ + "sizes": [3, 5, 6, 4, 6, 6], + "bound": 15, + }), + serde_json::json!({ + "sizes": [4, 5, 6, 4, 6, 5], + "bound": 14, + }), + ]; + + for invalid in invalid_cases { + assert!(serde_json::from_value::(invalid).is_err()); + } +} + +#[test] +#[should_panic(expected = "at least one element")] +fn test_three_partition_empty_sizes_panics() { + ThreePartition::new(vec![], 15); +} + +#[test] +#[should_panic(expected = "multiple of 3")] +fn test_three_partition_requires_three_m_elements() { + ThreePartition::new(vec![4, 5, 6, 4, 6], 15); +} + +#[test] +#[should_panic(expected = "positive")] +fn test_three_partition_rejects_zero_sizes() { + ThreePartition::new(vec![4, 5, 0, 4, 6, 5], 15); +} + +#[test] +#[should_panic(expected = "strictly between")] +fn test_three_partition_rejects_sizes_outside_strict_bounds() { + ThreePartition::new(vec![3, 5, 6, 4, 6, 6], 15); +} + +#[test] +#[should_panic(expected = "must equal m * bound")] +fn test_three_partition_rejects_wrong_total_sum() { + ThreePartition::new(vec![4, 5, 6, 4, 6, 5], 14); +}