Skip to content

Commit 8ee70ee

Browse files
GiggleLiuclaudezazabap
authored
Fix #403: Add SumOfSquaresPartition model (#663)
* Add plan for #403: SumOfSquaresPartition model * Implement #403: SumOfSquaresPartition model Add the Sum of Squares Partition satisfaction problem (Garey & Johnson SP19). Given positive integers, K groups, and bound J, determine whether the set can be partitioned into K groups with sum of squared group sums <= J. - Model: src/models/misc/sum_of_squares_partition.rs - Tests: 16 unit tests including paper example, edge cases, serialization - CLI: pred create SumOfSquaresPartition --sizes --num-groups --bound - Paper: problem-def entry in reductions.typ - Registry: declare_variants!, ProblemSchemaEntry, trait_consistency - Example DB: canonical model example with fixture regeneration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove plan file after implementation * fix: address PR #663 review comments * fix: address PR #663 review feedback * fix: add missing num_groups field to empty_args() in CLI tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: zazabap <sweynan@icloud.com>
1 parent 2de7770 commit 8ee70ee

11 files changed

Lines changed: 544 additions & 1 deletion

File tree

docs/paper/reductions.typ

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
"StaffScheduling": [Staff Scheduling],
121121
"MultiprocessorScheduling": [Multiprocessor Scheduling],
122122
"MinimumTardinessSequencing": [Minimum Tardiness Sequencing],
123+
"SumOfSquaresPartition": [Sum of Squares Partition],
123124
"SequencingWithinIntervals": [Sequencing Within Intervals],
124125
"DirectedTwoCommodityIntegralFlow": [Directed Two-Commodity Integral Flow],
125126
"StringToStringCorrection": [String-to-String Correction],
@@ -2301,6 +2302,14 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS
23012302
*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$.
23022303
]
23032304

2305+
#problem-def("SumOfSquaresPartition")[
2306+
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$.
2307+
][
2308+
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$).
2309+
2310+
*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.
2311+
]
2312+
23042313
#{
23052314
let x = load-model-example("SequencingWithReleaseTimesAndDeadlines")
23062315
let n = x.instance.lengths.len()

problemreductions-cli/src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ Flags by problem type:
232232
Factoring --target, --m, --n
233233
BinPacking --sizes, --capacity
234234
SubsetSum --sizes, --target
235+
SumOfSquaresPartition --sizes, --num-groups, --bound
235236
PaintShop --sequence
236237
MaximumSetPacking --sets [--weights]
237238
MinimumSetCovering --universe, --sets [--weights]
@@ -500,6 +501,9 @@ pub struct CreateArgs {
500501
/// Alphabet size for LCS, SCS, or StringToStringCorrection (optional; inferred from the input strings if omitted)
501502
#[arg(long)]
502503
pub alphabet_size: Option<usize>,
504+
/// Number of groups for SumOfSquaresPartition
505+
#[arg(long)]
506+
pub num_groups: Option<usize>,
503507
/// Functional dependencies for MinimumCardinalityKey (semicolon-separated "lhs>rhs" pairs, e.g., "0,1>2;0,2>3")
504508
#[arg(long)]
505509
pub dependencies: Option<String>,

problemreductions-cli/src/commands/create.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use problemreductions::models::misc::{
1616
BinPacking, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing,
1717
MultiprocessorScheduling, PaintShop, SequencingWithReleaseTimesAndDeadlines,
1818
SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum,
19+
SumOfSquaresPartition,
1920
};
2021
use problemreductions::models::BiconnectivityAugmentation;
2122
use problemreductions::prelude::*;
@@ -94,6 +95,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
9495
&& args.requirements.is_none()
9596
&& args.num_workers.is_none()
9697
&& args.alphabet_size.is_none()
98+
&& args.num_groups.is_none()
9799
&& args.dependencies.is_none()
98100
&& args.num_attributes.is_none()
99101
&& args.source_string.is_none()
@@ -328,6 +330,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
328330
}
329331
"SubgraphIsomorphism" => "--graph 0-1,1-2,2-0 --pattern 0-1",
330332
"SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11",
333+
"SumOfSquaresPartition" => "--sizes 5,3,8,2,7,1 --num-groups 3 --bound 240",
331334
"ComparativeContainment" => {
332335
"--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"
333336
}
@@ -1044,6 +1047,34 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
10441047
)
10451048
}
10461049

1050+
// SumOfSquaresPartition
1051+
"SumOfSquaresPartition" => {
1052+
let sizes_str = args.sizes.as_deref().ok_or_else(|| {
1053+
anyhow::anyhow!(
1054+
"SumOfSquaresPartition requires --sizes, --num-groups, and --bound\n\n\
1055+
Usage: pred create SumOfSquaresPartition --sizes 5,3,8,2,7,1 --num-groups 3 --bound 240"
1056+
)
1057+
})?;
1058+
let num_groups = args.num_groups.ok_or_else(|| {
1059+
anyhow::anyhow!(
1060+
"SumOfSquaresPartition requires --num-groups\n\n\
1061+
Usage: pred create SumOfSquaresPartition --sizes 5,3,8,2,7,1 --num-groups 3 --bound 240"
1062+
)
1063+
})?;
1064+
let bound = args.bound.ok_or_else(|| {
1065+
anyhow::anyhow!(
1066+
"SumOfSquaresPartition requires --bound\n\n\
1067+
Usage: pred create SumOfSquaresPartition --sizes 5,3,8,2,7,1 --num-groups 3 --bound 240"
1068+
)
1069+
})?;
1070+
let sizes: Vec<i64> = util::parse_comma_list(sizes_str)?;
1071+
(
1072+
ser(SumOfSquaresPartition::try_new(sizes, num_groups, bound)
1073+
.map_err(anyhow::Error::msg)?)?,
1074+
resolved_variant.clone(),
1075+
)
1076+
}
1077+
10471078
// PaintShop
10481079
"PaintShop" => {
10491080
let seq_str = args.sequence.as_deref().ok_or_else(|| {
@@ -3451,6 +3482,7 @@ mod tests {
34513482
schedules: None,
34523483
requirements: None,
34533484
num_workers: None,
3485+
num_groups: None,
34543486
}
34553487
}
34563488

problemreductions-cli/tests/cli_tests.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,6 +1361,29 @@ fn test_create_set_basis_rejects_out_of_range_elements() {
13611361
assert!(!stderr.contains("panicked at"), "stderr: {stderr}");
13621362
}
13631363

1364+
#[test]
1365+
fn test_create_sum_of_squares_partition_rejects_negative_bound_without_panicking() {
1366+
let output = pred()
1367+
.args([
1368+
"create",
1369+
"SumOfSquaresPartition",
1370+
"--sizes",
1371+
"1,2,3",
1372+
"--num-groups",
1373+
"2",
1374+
"--bound=-1",
1375+
])
1376+
.output()
1377+
.unwrap();
1378+
assert!(!output.status.success());
1379+
let stderr = String::from_utf8_lossy(&output.stderr);
1380+
assert!(
1381+
stderr.contains("Bound must be nonnegative"),
1382+
"stderr: {stderr}"
1383+
);
1384+
assert!(!stderr.contains("panicked at"), "stderr: {stderr}");
1385+
}
1386+
13641387
#[test]
13651388
fn test_create_minimum_cardinality_key_problem_help_uses_supported_flags() {
13661389
let output = pred()
@@ -3851,6 +3874,34 @@ fn test_create_pipe_to_solve() {
38513874
);
38523875
}
38533876

3877+
#[test]
3878+
fn test_solve_ilp_error_suggests_brute_force_fallback() {
3879+
let problem_json = r#"{
3880+
"type": "SumOfSquaresPartition",
3881+
"data": {
3882+
"sizes": [5, 3, 8, 2, 7, 1],
3883+
"num_groups": 3,
3884+
"bound": 240
3885+
}
3886+
}"#;
3887+
let tmp = std::env::temp_dir().join("pred_test_sum_of_squares_partition.json");
3888+
std::fs::write(&tmp, problem_json).unwrap();
3889+
3890+
let output = pred()
3891+
.args(["solve", tmp.to_str().unwrap()])
3892+
.output()
3893+
.unwrap();
3894+
assert!(!output.status.success());
3895+
3896+
let stderr = String::from_utf8_lossy(&output.stderr);
3897+
assert!(
3898+
stderr.contains("--solver brute-force"),
3899+
"stderr should suggest the brute-force fallback, got: {stderr}"
3900+
);
3901+
3902+
std::fs::remove_file(&tmp).ok();
3903+
}
3904+
38543905
#[test]
38553906
fn test_create_multiple_choice_branching_pipe_to_solve() {
38563907
let create_out = pred()

src/example_db/fixtures/examples.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
{"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}}]},
5050
{"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}]},
5151
{"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}]},
52+
{"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}]},
5253
{"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}}]},
5354
{"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}]}
5455
],

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ pub mod prelude {
6262
MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop,
6363
SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals,
6464
ShortestCommonSupersequence, StaffScheduling, StringToStringCorrection, SubsetSum,
65+
SumOfSquaresPartition,
6566
};
6667
pub use crate::models::set::{
6768
ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking,

src/models/misc/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
//! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length
1515
//! - [`StringToStringCorrection`]: String-to-String Correction (derive target via deletions and swaps)
1616
//! - [`SubsetSum`]: Find a subset summing to exactly a target value
17+
//! - [`SumOfSquaresPartition`]: Partition integers into K groups minimizing sum of squared group sums
1718
1819
mod bin_packing;
1920
pub(crate) mod factoring;
@@ -29,6 +30,7 @@ pub(crate) mod shortest_common_supersequence;
2930
mod staff_scheduling;
3031
pub(crate) mod string_to_string_correction;
3132
mod subset_sum;
33+
pub(crate) mod sum_of_squares_partition;
3234

3335
pub use bin_packing::BinPacking;
3436
pub use factoring::Factoring;
@@ -44,6 +46,7 @@ pub use shortest_common_supersequence::ShortestCommonSupersequence;
4446
pub use staff_scheduling::StaffScheduling;
4547
pub use string_to_string_correction::StringToStringCorrection;
4648
pub use subset_sum::SubsetSum;
49+
pub use sum_of_squares_partition::SumOfSquaresPartition;
4750

4851
#[cfg(feature = "example-db")]
4952
pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
@@ -57,6 +60,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::M
5760
specs.extend(shortest_common_supersequence::canonical_model_example_specs());
5861
specs.extend(string_to_string_correction::canonical_model_example_specs());
5962
specs.extend(minimum_tardiness_sequencing::canonical_model_example_specs());
63+
specs.extend(sum_of_squares_partition::canonical_model_example_specs());
6064
specs.extend(sequencing_with_release_times_and_deadlines::canonical_model_example_specs());
6165
specs
6266
}

0 commit comments

Comments
 (0)