diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a0dafe9c..63ec58e7 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -120,6 +120,7 @@ "StaffScheduling": [Staff Scheduling], "MultiprocessorScheduling": [Multiprocessor Scheduling], "MinimumTardinessSequencing": [Minimum Tardiness Sequencing], + "SumOfSquaresPartition": [Sum of Squares Partition], "SequencingWithinIntervals": [Sequencing Within Intervals], "DirectedTwoCommodityIntegralFlow": [Directed Two-Commodity Integral Flow], "StringToStringCorrection": [String-to-String Correction], @@ -2301,6 +2302,14 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS *Example.* Let $A = {3, 7, 1, 8, 2, 4}$ ($n = 6$) and target $B = 11$. Selecting $A' = {3, 8}$ gives sum $3 + 8 = 11 = B$. Another solution: $A' = {7, 4}$ with sum $7 + 4 = 11 = B$. ] +#problem-def("SumOfSquaresPartition")[ + Given a finite set $A = {a_0, dots, a_(n-1)}$ with sizes $s(a_i) in ZZ^+$, a positive integer $K lt.eq |A|$ (number of groups), and a positive integer $J$ (bound), determine whether $A$ can be partitioned into $K$ disjoint sets $A_1, dots, A_K$ such that $sum_(i=1)^K (sum_(a in A_i) s(a))^2 lt.eq J$. +][ + Problem SP19 in Garey and Johnson @garey1979. NP-complete in the strong sense, so no pseudo-polynomial time algorithm exists unless $P = NP$. For fixed $K$, a dynamic-programming algorithm runs in $O(n S^(K-1))$ pseudo-polynomial time, where $S = sum s(a)$. The problem remains NP-complete when the exponent 2 is replaced by any fixed rational $alpha > 1$. #footnote[No algorithm improving on brute-force $O(K^n)$ enumeration is known for the general case.] The squared objective penalizes imbalanced partitions, connecting it to variance minimization, load balancing, and $k$-means clustering. Sum of Squares Partition generalizes Partition ($K = 2$, $J = S^2 slash 2$). + + *Example.* Let $A = {5, 3, 8, 2, 7, 1}$ ($n = 6$), $K = 3$ groups, and bound $J = 240$. 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 lt.eq 240 = J$. With a tighter bound $J = 225$, the best achievable partition has group sums ${9, 9, 8}$ yielding $81 + 81 + 64 = 226 > 225$, so the answer is NO. +] + #{ 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 27e2bbca..c946abe4 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -232,6 +232,7 @@ Flags by problem type: Factoring --target, --m, --n BinPacking --sizes, --capacity SubsetSum --sizes, --target + SumOfSquaresPartition --sizes, --num-groups, --bound PaintShop --sequence MaximumSetPacking --sets [--weights] MinimumSetCovering --universe, --sets [--weights] @@ -500,6 +501,9 @@ pub struct CreateArgs { /// Alphabet size for LCS, SCS, or StringToStringCorrection (optional; inferred from the input strings if omitted) #[arg(long)] pub alphabet_size: Option, + /// Number of groups for SumOfSquaresPartition + #[arg(long)] + pub num_groups: Option, /// Functional dependencies for MinimumCardinalityKey (semicolon-separated "lhs>rhs" pairs, e.g., "0,1>2;0,2>3") #[arg(long)] pub dependencies: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index e88054a0..56da8a97 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -16,6 +16,7 @@ use problemreductions::models::misc::{ BinPacking, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, + SumOfSquaresPartition, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -94,6 +95,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.requirements.is_none() && args.num_workers.is_none() && args.alphabet_size.is_none() + && args.num_groups.is_none() && args.dependencies.is_none() && args.num_attributes.is_none() && args.source_string.is_none() @@ -328,6 +330,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } "SubgraphIsomorphism" => "--graph 0-1,1-2,2-0 --pattern 0-1", "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", + "SumOfSquaresPartition" => "--sizes 5,3,8,2,7,1 --num-groups 3 --bound 240", "ComparativeContainment" => { "--universe 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" --r-weights 2,5 --s-weights 3,6" } @@ -1044,6 +1047,34 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // SumOfSquaresPartition + "SumOfSquaresPartition" => { + let sizes_str = args.sizes.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SumOfSquaresPartition requires --sizes, --num-groups, and --bound\n\n\ + Usage: pred create SumOfSquaresPartition --sizes 5,3,8,2,7,1 --num-groups 3 --bound 240" + ) + })?; + let num_groups = args.num_groups.ok_or_else(|| { + anyhow::anyhow!( + "SumOfSquaresPartition requires --num-groups\n\n\ + Usage: pred create SumOfSquaresPartition --sizes 5,3,8,2,7,1 --num-groups 3 --bound 240" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "SumOfSquaresPartition requires --bound\n\n\ + Usage: pred create SumOfSquaresPartition --sizes 5,3,8,2,7,1 --num-groups 3 --bound 240" + ) + })?; + let sizes: Vec = util::parse_comma_list(sizes_str)?; + ( + ser(SumOfSquaresPartition::try_new(sizes, num_groups, bound) + .map_err(anyhow::Error::msg)?)?, + resolved_variant.clone(), + ) + } + // PaintShop "PaintShop" => { let seq_str = args.sequence.as_deref().ok_or_else(|| { @@ -3451,6 +3482,7 @@ mod tests { schedules: None, requirements: None, num_workers: None, + num_groups: None, } } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 1a94f07e..d2a6ffa9 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1361,6 +1361,29 @@ fn test_create_set_basis_rejects_out_of_range_elements() { assert!(!stderr.contains("panicked at"), "stderr: {stderr}"); } +#[test] +fn test_create_sum_of_squares_partition_rejects_negative_bound_without_panicking() { + let output = pred() + .args([ + "create", + "SumOfSquaresPartition", + "--sizes", + "1,2,3", + "--num-groups", + "2", + "--bound=-1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Bound must be nonnegative"), + "stderr: {stderr}" + ); + assert!(!stderr.contains("panicked at"), "stderr: {stderr}"); +} + #[test] fn test_create_minimum_cardinality_key_problem_help_uses_supported_flags() { let output = pred() @@ -3851,6 +3874,34 @@ fn test_create_pipe_to_solve() { ); } +#[test] +fn test_solve_ilp_error_suggests_brute_force_fallback() { + let problem_json = r#"{ + "type": "SumOfSquaresPartition", + "data": { + "sizes": [5, 3, 8, 2, 7, 1], + "num_groups": 3, + "bound": 240 + } + }"#; + let tmp = std::env::temp_dir().join("pred_test_sum_of_squares_partition.json"); + std::fs::write(&tmp, problem_json).unwrap(); + + let output = pred() + .args(["solve", tmp.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--solver brute-force"), + "stderr should suggest the brute-force fallback, got: {stderr}" + ); + + std::fs::remove_file(&tmp).ok(); +} + #[test] fn test_create_multiple_choice_branching_pipe_to_solve() { let create_out = pred() diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index d954a60d..cc0a8e78 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -49,6 +49,7 @@ {"problem":"SteinerTree","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[2,5,2,1,5,6,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"terminals":[0,2,4]},"samples":[{"config":[1,0,1,1,0,0,1],"metric":{"Valid":6}}],"optimal":[{"config":[1,0,1,1,0,0,1],"metric":{"Valid":6}}]}, {"problem":"StringToStringCorrection","variant":{},"instance":{"alphabet_size":4,"bound":2,"source":[0,1,2,3,1,0],"target":[0,1,3,2,1]},"samples":[{"config":[8,5],"metric":true}],"optimal":[{"config":[5,7],"metric":true},{"config":[8,5],"metric":true}]}, {"problem":"StrongConnectivityAugmentation","variant":{"weight":"i32"},"instance":{"bound":8,"candidate_arcs":[[4,0,10],[4,3,3],[4,2,3],[4,1,3],[3,0,7],[3,1,3],[2,0,7],[2,1,3],[1,0,5]],"graph":{"inner":{"edge_property":"directed","edges":[[0,1,null],[1,2,null],[2,3,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}}},"samples":[{"config":[0,0,0,1,0,0,0,0,1],"metric":true},{"config":[0,0,0,0,0,0,0,0,0],"metric":false}],"optimal":[{"config":[0,0,0,1,0,0,0,0,1],"metric":true}]}, + {"problem":"SumOfSquaresPartition","variant":{},"instance":{"bound":240,"num_groups":3,"sizes":[5,3,8,2,7,1]},"samples":[{"config":[1,2,0,1,2,0],"metric":true}],"optimal":[{"config":[0,0,1,0,2,0],"metric":true},{"config":[0,0,1,0,2,1],"metric":true},{"config":[0,0,1,0,2,2],"metric":true},{"config":[0,0,1,1,2,0],"metric":true},{"config":[0,0,1,1,2,1],"metric":true},{"config":[0,0,1,1,2,2],"metric":true},{"config":[0,0,1,2,2,0],"metric":true},{"config":[0,0,1,2,2,1],"metric":true},{"config":[0,0,1,2,2,2],"metric":true},{"config":[0,0,2,0,1,0],"metric":true},{"config":[0,0,2,0,1,1],"metric":true},{"config":[0,0,2,0,1,2],"metric":true},{"config":[0,0,2,1,1,0],"metric":true},{"config":[0,0,2,1,1,1],"metric":true},{"config":[0,0,2,1,1,2],"metric":true},{"config":[0,0,2,2,1,0],"metric":true},{"config":[0,0,2,2,1,1],"metric":true},{"config":[0,0,2,2,1,2],"metric":true},{"config":[0,1,1,0,2,0],"metric":true},{"config":[0,1,1,0,2,2],"metric":true},{"config":[0,1,1,2,2,0],"metric":true},{"config":[0,1,2,0,1,0],"metric":true},{"config":[0,1,2,0,1,1],"metric":true},{"config":[0,1,2,0,1,2],"metric":true},{"config":[0,1,2,2,1,0],"metric":true},{"config":[0,2,1,0,2,0],"metric":true},{"config":[0,2,1,0,2,1],"metric":true},{"config":[0,2,1,0,2,2],"metric":true},{"config":[0,2,1,1,2,0],"metric":true},{"config":[0,2,2,0,1,0],"metric":true},{"config":[0,2,2,0,1,1],"metric":true},{"config":[0,2,2,1,1,0],"metric":true},{"config":[1,0,0,1,2,1],"metric":true},{"config":[1,0,0,1,2,2],"metric":true},{"config":[1,0,0,2,2,1],"metric":true},{"config":[1,0,2,1,0,0],"metric":true},{"config":[1,0,2,1,0,1],"metric":true},{"config":[1,0,2,1,0,2],"metric":true},{"config":[1,0,2,2,0,1],"metric":true},{"config":[1,1,0,0,2,0],"metric":true},{"config":[1,1,0,0,2,1],"metric":true},{"config":[1,1,0,0,2,2],"metric":true},{"config":[1,1,0,1,2,0],"metric":true},{"config":[1,1,0,1,2,1],"metric":true},{"config":[1,1,0,1,2,2],"metric":true},{"config":[1,1,0,2,2,0],"metric":true},{"config":[1,1,0,2,2,1],"metric":true},{"config":[1,1,0,2,2,2],"metric":true},{"config":[1,1,2,0,0,0],"metric":true},{"config":[1,1,2,0,0,1],"metric":true},{"config":[1,1,2,0,0,2],"metric":true},{"config":[1,1,2,1,0,0],"metric":true},{"config":[1,1,2,1,0,1],"metric":true},{"config":[1,1,2,1,0,2],"metric":true},{"config":[1,1,2,2,0,0],"metric":true},{"config":[1,1,2,2,0,1],"metric":true},{"config":[1,1,2,2,0,2],"metric":true},{"config":[1,2,0,0,2,1],"metric":true},{"config":[1,2,0,1,2,0],"metric":true},{"config":[1,2,0,1,2,1],"metric":true},{"config":[1,2,0,1,2,2],"metric":true},{"config":[1,2,2,0,0,1],"metric":true},{"config":[1,2,2,1,0,0],"metric":true},{"config":[1,2,2,1,0,1],"metric":true},{"config":[2,0,0,1,1,2],"metric":true},{"config":[2,0,0,2,1,1],"metric":true},{"config":[2,0,0,2,1,2],"metric":true},{"config":[2,0,1,1,0,2],"metric":true},{"config":[2,0,1,2,0,0],"metric":true},{"config":[2,0,1,2,0,1],"metric":true},{"config":[2,0,1,2,0,2],"metric":true},{"config":[2,1,0,0,1,2],"metric":true},{"config":[2,1,0,2,1,0],"metric":true},{"config":[2,1,0,2,1,1],"metric":true},{"config":[2,1,0,2,1,2],"metric":true},{"config":[2,1,1,0,0,2],"metric":true},{"config":[2,1,1,2,0,0],"metric":true},{"config":[2,1,1,2,0,2],"metric":true},{"config":[2,2,0,0,1,0],"metric":true},{"config":[2,2,0,0,1,1],"metric":true},{"config":[2,2,0,0,1,2],"metric":true},{"config":[2,2,0,1,1,0],"metric":true},{"config":[2,2,0,1,1,1],"metric":true},{"config":[2,2,0,1,1,2],"metric":true},{"config":[2,2,0,2,1,0],"metric":true},{"config":[2,2,0,2,1,1],"metric":true},{"config":[2,2,0,2,1,2],"metric":true},{"config":[2,2,1,0,0,0],"metric":true},{"config":[2,2,1,0,0,1],"metric":true},{"config":[2,2,1,0,0,2],"metric":true},{"config":[2,2,1,1,0,0],"metric":true},{"config":[2,2,1,1,0,1],"metric":true},{"config":[2,2,1,1,0,2],"metric":true},{"config":[2,2,1,2,0,0],"metric":true},{"config":[2,2,1,2,0,1],"metric":true},{"config":[2,2,1,2,0,2],"metric":true}]}, {"problem":"TravelingSalesman","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_weights":[1,3,2,2,3,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[0,3,null],[1,2,null],[1,3,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}}},"samples":[{"config":[1,0,1,1,0,1],"metric":{"Valid":6}}],"optimal":[{"config":[1,0,1,1,0,1],"metric":{"Valid":6}}]}, {"problem":"UndirectedTwoCommodityIntegralFlow","variant":{},"instance":{"capacities":[1,1,2],"graph":{"inner":{"edge_property":"undirected","edges":[[0,2,null],[1,2,null],[2,3,null]],"node_holes":[],"nodes":[null,null,null,null]}},"requirement_1":1,"requirement_2":1,"sink_1":3,"sink_2":3,"source_1":0,"source_2":1},"samples":[{"config":[1,0,0,0,0,0,1,0,1,0,1,0],"metric":true}],"optimal":[{"config":[0,0,1,0,1,0,0,0,1,0,1,0],"metric":true},{"config":[1,0,0,0,0,0,1,0,1,0,1,0],"metric":true}]} ], diff --git a/src/lib.rs b/src/lib.rs index edeb203f..8af9c695 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,7 @@ pub mod prelude { MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, StaffScheduling, StringToStringCorrection, SubsetSum, + SumOfSquaresPartition, }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index abad5871..d349bec4 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -14,6 +14,7 @@ //! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length //! - [`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 mod bin_packing; pub(crate) mod factoring; @@ -29,6 +30,7 @@ pub(crate) mod shortest_common_supersequence; mod staff_scheduling; pub(crate) mod string_to_string_correction; mod subset_sum; +pub(crate) mod sum_of_squares_partition; pub use bin_packing::BinPacking; pub use factoring::Factoring; @@ -44,6 +46,7 @@ pub use shortest_common_supersequence::ShortestCommonSupersequence; pub use staff_scheduling::StaffScheduling; pub use string_to_string_correction::StringToStringCorrection; pub use subset_sum::SubsetSum; +pub use sum_of_squares_partition::SumOfSquaresPartition; #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { @@ -57,6 +60,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Positive integer size s(a) for each element a in A" }, + FieldInfo { name: "num_groups", type_name: "usize", description: "Number of groups K in the partition" }, + FieldInfo { name: "bound", type_name: "i64", description: "Upper bound J on the sum of squared group sums" }, + ], + } +} + +/// The Sum of Squares Partition problem (Garey & Johnson SP19). +/// +/// Given a finite set `A` with sizes `s(a) ∈ Z⁺` for each `a ∈ A`, +/// a positive integer `K ≤ |A|` (number of groups), and a positive +/// integer `J` (bound), determine whether `A` can be partitioned into +/// `K` disjoint sets `A_1, ..., A_K` such that: +/// +/// `∑_{i=1}^{K} (∑_{a ∈ A_i} s(a))² ≤ J` +/// +/// # Representation +/// +/// Each element has a variable in `{0, ..., K-1}` representing its +/// group assignment. A configuration is satisfying if the sum of +/// squared group sums does not exceed `J`. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::misc::SumOfSquaresPartition; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // 6 elements with sizes [5, 3, 8, 2, 7, 1], K=3 groups, bound J=240 +/// let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240); +/// let solver = BruteForce::new(); +/// let solution = solver.find_satisfying(&problem); +/// assert!(solution.is_some()); +/// ``` +#[derive(Debug, Clone, Serialize)] +pub struct SumOfSquaresPartition { + /// Positive integer sizes for each element. + sizes: Vec, + /// Number of groups K. + num_groups: usize, + /// Upper bound J on the sum of squared group sums. + bound: i64, +} + +impl SumOfSquaresPartition { + fn validate_inputs(sizes: &[i64], num_groups: usize, bound: i64) -> Result<(), String> { + if sizes.iter().any(|&size| size <= 0) { + return Err("All sizes must be positive (> 0)".to_string()); + } + if num_groups == 0 { + return Err("Number of groups must be positive".to_string()); + } + if num_groups > sizes.len() { + return Err("Number of groups must not exceed number of elements".to_string()); + } + if bound < 0 { + return Err("Bound must be nonnegative".to_string()); + } + Ok(()) + } + + /// Create a new SumOfSquaresPartition instance, returning validation errors. + pub fn try_new(sizes: Vec, num_groups: usize, bound: i64) -> Result { + Self::validate_inputs(&sizes, num_groups, bound)?; + Ok(Self { + sizes, + num_groups, + bound, + }) + } + + /// Create a new SumOfSquaresPartition instance. + /// + /// # Panics + /// + /// Panics if any size is not positive (must be > 0), if `num_groups` is 0, + /// if `num_groups` exceeds the number of elements, or if `bound` is negative. + pub fn new(sizes: Vec, num_groups: usize, bound: i64) -> Self { + Self::try_new(sizes, num_groups, bound).unwrap_or_else(|message| panic!("{message}")) + } + + /// Returns the element sizes. + pub fn sizes(&self) -> &[i64] { + &self.sizes + } + + /// Returns the number of groups K. + pub fn num_groups(&self) -> usize { + self.num_groups + } + + /// Returns the bound J. + pub fn bound(&self) -> i64 { + self.bound + } + + /// Returns the number of elements |A|. + pub fn num_elements(&self) -> usize { + self.sizes.len() + } + + /// Compute the sum of squared group sums for a given configuration. + /// + /// Returns `None` if the configuration is invalid (wrong length or + /// out-of-range group index), or if arithmetic overflows `i64`. + pub fn sum_of_squares(&self, config: &[usize]) -> Option { + if config.len() != self.sizes.len() { + return None; + } + let mut group_sums = vec![0i128; self.num_groups]; + for (i, &g) in config.iter().enumerate() { + if g >= self.num_groups { + return None; + } + group_sums[g] = group_sums[g].checked_add(i128::from(self.sizes[i]))?; + } + group_sums + .into_iter() + .try_fold(0i128, |total, group_sum| { + let square = group_sum.checked_mul(group_sum)?; + total.checked_add(square) + }) + .and_then(|total| i64::try_from(total).ok()) + } +} + +#[derive(Deserialize)] +struct SumOfSquaresPartitionData { + sizes: Vec, + num_groups: usize, + bound: i64, +} + +impl<'de> Deserialize<'de> for SumOfSquaresPartition { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let data = SumOfSquaresPartitionData::deserialize(deserializer)?; + Self::try_new(data.sizes, data.num_groups, data.bound).map_err(D::Error::custom) + } +} + +impl Problem for SumOfSquaresPartition { + const NAME: &'static str = "SumOfSquaresPartition"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![self.num_groups; self.sizes.len()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + match self.sum_of_squares(config) { + Some(sos) => sos <= self.bound, + None => false, + } + } +} + +impl SatisfactionProblem for SumOfSquaresPartition {} + +crate::declare_variants! { + default sat SumOfSquaresPartition => "num_groups^num_elements", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "sum_of_squares_partition", + build: || { + // sizes=[5,3,8,2,7,1], K=3, J=240 + // Satisfying: groups {8,1},{5,2},{3,7} -> sums 9,7,10 -> 81+49+100=230 <= 240 + // Config: a0=5->group1, a1=3->group2, a2=8->group0, a3=2->group1, a4=7->group2, a5=1->group0 + let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240); + crate::example_db::specs::satisfaction_example(problem, vec![vec![1, 2, 0, 1, 2, 0]]) + }, + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/sum_of_squares_partition.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 7bd03578..50acaa59 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -25,7 +25,7 @@ pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, - StaffScheduling, StringToStringCorrection, SubsetSum, + StaffScheduling, StringToStringCorrection, SubsetSum, SumOfSquaresPartition, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/unit_tests/models/misc/sum_of_squares_partition.rs b/src/unit_tests/models/misc/sum_of_squares_partition.rs new file mode 100644 index 00000000..f0defb70 --- /dev/null +++ b/src/unit_tests/models/misc/sum_of_squares_partition.rs @@ -0,0 +1,230 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; + +#[test] +fn test_sum_of_squares_partition_basic() { + let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240); + assert_eq!(problem.num_elements(), 6); + assert_eq!(problem.num_groups(), 3); + assert_eq!(problem.bound(), 240); + assert_eq!(problem.sizes(), &[5, 3, 8, 2, 7, 1]); + assert_eq!(problem.dims(), vec![3; 6]); + assert_eq!( + ::NAME, + "SumOfSquaresPartition" + ); + assert_eq!(::variant(), vec![]); +} + +#[test] +fn test_sum_of_squares_partition_evaluate_satisfying() { + let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240); + // Groups: {8,1}=9, {5,2}=7, {3,7}=10 -> 81+49+100=230 <= 240 + assert!(problem.evaluate(&[1, 2, 0, 1, 2, 0])); +} + +#[test] +fn test_sum_of_squares_partition_evaluate_unsatisfying() { + let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 225); + // Best achievable: sums {9,9,8} -> 81+81+64=226 > 225 + // Groups: {8,1}=9, {5,2}=7, {3,7}=10 -> 81+49+100=230 > 225 + assert!(!problem.evaluate(&[1, 2, 0, 1, 2, 0])); +} + +#[test] +fn test_sum_of_squares_partition_all_in_one_group() { + // All elements in one group is maximally imbalanced + let problem = SumOfSquaresPartition::new(vec![1, 2, 3], 2, 100); + // All in group 0: sum=6, group1=0 -> 36+0=36 <= 100 + assert!(problem.evaluate(&[0, 0, 0])); + // Tight bound: all in group 0 gives 36 + let problem2 = SumOfSquaresPartition::new(vec![1, 2, 3], 2, 35); + assert!(!problem2.evaluate(&[0, 0, 0])); // 36 > 35 +} + +#[test] +fn test_sum_of_squares_partition_sum_of_squares_helper() { + let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240); + // Groups: {8,1}=9, {5,2}=7, {3,7}=10 -> 81+49+100=230 + assert_eq!(problem.sum_of_squares(&[1, 2, 0, 1, 2, 0]), Some(230)); +} + +#[test] +fn test_sum_of_squares_partition_invalid_config() { + let problem = SumOfSquaresPartition::new(vec![1, 2, 3], 2, 100); + // Wrong length + assert!(!problem.evaluate(&[0, 0])); + assert!(!problem.evaluate(&[0, 0, 0, 0])); + // Group index out of range + assert!(!problem.evaluate(&[0, 2, 0])); + // sum_of_squares returns None for invalid configs + assert_eq!(problem.sum_of_squares(&[0, 0]), None); + assert_eq!(problem.sum_of_squares(&[0, 2, 0]), None); +} + +#[test] +fn test_sum_of_squares_partition_two_elements() { + // Two elements, 2 groups: balanced vs imbalanced + let problem = SumOfSquaresPartition::new(vec![3, 5], 2, 34); + // {3},{5} -> 9+25=34 <= 34 + assert!(problem.evaluate(&[0, 1])); + // {3,5},{} -> 64+0=64 > 34 + assert!(!problem.evaluate(&[0, 0])); + // {},{3,5} -> 0+64=64 > 34 + assert!(!problem.evaluate(&[1, 1])); +} + +#[test] +fn test_sum_of_squares_partition_brute_force() { + let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240); + let solver = BruteForce::new(); + let solution = solver + .find_satisfying(&problem) + .expect("should find a satisfying solution"); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_sum_of_squares_partition_brute_force_all() { + let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240); + let solver = BruteForce::new(); + let solutions = solver.find_all_satisfying(&problem); + assert!(!solutions.is_empty()); + for sol in &solutions { + assert!(problem.evaluate(sol)); + } +} + +#[test] +fn test_sum_of_squares_partition_unsatisfiable() { + // Bound too tight: impossible to satisfy + // 3 elements [10, 10, 10], 3 groups, bound 299 + // Best: each element in its own group -> 100+100+100=300 > 299 + let problem = SumOfSquaresPartition::new(vec![10, 10, 10], 3, 299); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_none()); +} + +#[test] +fn test_sum_of_squares_partition_serialization() { + let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240); + let json = serde_json::to_value(&problem).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "sizes": [5, 3, 8, 2, 7, 1], + "num_groups": 3, + "bound": 240, + }) + ); + let restored: SumOfSquaresPartition = serde_json::from_value(json).unwrap(); + assert_eq!(restored.sizes(), problem.sizes()); + assert_eq!(restored.num_groups(), problem.num_groups()); + assert_eq!(restored.bound(), problem.bound()); +} + +#[test] +fn test_sum_of_squares_partition_deserialization_rejects_invalid_fields() { + let invalid_cases = [ + serde_json::json!({ + "sizes": [-1, 2, 3], + "num_groups": 2, + "bound": 100, + }), + serde_json::json!({ + "sizes": [0, 2, 3], + "num_groups": 2, + "bound": 100, + }), + serde_json::json!({ + "sizes": [1, 2, 3], + "num_groups": 0, + "bound": 100, + }), + serde_json::json!({ + "sizes": [1, 2], + "num_groups": 3, + "bound": 100, + }), + serde_json::json!({ + "sizes": [1, 2, 3], + "num_groups": 2, + "bound": -1, + }), + ]; + + for invalid in invalid_cases { + assert!(serde_json::from_value::(invalid).is_err()); + } +} + +#[test] +fn test_sum_of_squares_partition_sum_overflow_returns_none() { + let problem = SumOfSquaresPartition::new(vec![i64::MAX, 1], 1, i64::MAX); + + assert_eq!(problem.sum_of_squares(&[0, 0]), None); + assert!(!problem.evaluate(&[0, 0])); +} + +#[test] +fn test_sum_of_squares_partition_square_overflow_returns_none() { + let problem = SumOfSquaresPartition::new(vec![3_037_000_500], 1, i64::MAX); + + assert_eq!(problem.sum_of_squares(&[0]), None); + assert!(!problem.evaluate(&[0])); +} + +#[test] +fn test_sum_of_squares_partition_paper_example() { + // Instance from the issue: sizes=[5,3,8,2,7,1], K=3, J=240 + let problem = SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240); + + // Verify the satisfying partition from the issue: + // A1={8,1}(sums to 9), A2={5,2}(sums to 7), A3={3,7}(sums to 10) + // Config: a0=5->group1, a1=3->group2, a2=8->group0, a3=2->group1, a4=7->group2, a5=1->group0 + let config = vec![1, 2, 0, 1, 2, 0]; + assert!(problem.evaluate(&config)); + assert_eq!(problem.sum_of_squares(&config), Some(230)); + + // Brute force finds satisfying solutions + let solver = BruteForce::new(); + let all = solver.find_all_satisfying(&problem); + assert!(!all.is_empty()); + // All solutions must have sum-of-squares <= 240 + for sol in &all { + let sos = problem.sum_of_squares(sol).unwrap(); + assert!(sos <= 240); + } +} + +#[test] +#[should_panic(expected = "positive")] +fn test_sum_of_squares_partition_negative_size_panics() { + SumOfSquaresPartition::new(vec![-1, 2, 3], 2, 100); +} + +#[test] +#[should_panic(expected = "positive")] +fn test_sum_of_squares_partition_zero_size_panics() { + SumOfSquaresPartition::new(vec![0, 2, 3], 2, 100); +} + +#[test] +#[should_panic(expected = "Number of groups must be positive")] +fn test_sum_of_squares_partition_zero_groups_panics() { + SumOfSquaresPartition::new(vec![1, 2, 3], 0, 100); +} + +#[test] +#[should_panic(expected = "Number of groups must not exceed")] +fn test_sum_of_squares_partition_too_many_groups_panics() { + SumOfSquaresPartition::new(vec![1, 2], 3, 100); +} + +#[test] +#[should_panic(expected = "Bound must be nonnegative")] +fn test_sum_of_squares_partition_negative_bound_panics() { + SumOfSquaresPartition::new(vec![1, 2, 3], 2, -1); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index cc81aba4..a142dc4a 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -106,6 +106,10 @@ fn test_all_problems_implement_trait_correctly() { &SequencingWithReleaseTimesAndDeadlines::new(vec![1, 2, 1], vec![0, 0, 2], vec![3, 3, 4]), "SequencingWithReleaseTimesAndDeadlines", ); + check_problem_trait( + &SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240), + "SumOfSquaresPartition", + ); } #[test]