From 7ab8da09048da7deb4ffae1cebb417337f4a8bad Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 18:19:11 +0800 Subject: [PATCH 1/6] Add plan for #403: SumOfSquaresPartition model --- .../2026-03-16-sum-of-squares-partition.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/plans/2026-03-16-sum-of-squares-partition.md diff --git a/docs/plans/2026-03-16-sum-of-squares-partition.md b/docs/plans/2026-03-16-sum-of-squares-partition.md new file mode 100644 index 000000000..702efeb19 --- /dev/null +++ b/docs/plans/2026-03-16-sum-of-squares-partition.md @@ -0,0 +1,85 @@ +# Plan: Add SumOfSquaresPartition Model (#403) + +## Summary + +Implement the SumOfSquaresPartition satisfaction problem (Garey & Johnson SP19). +Given a finite set A of positive integers, K groups, and bound J, determine whether A +can be partitioned into K disjoint groups such that the sum of squared group sums <= J. + +## Batch 1: Implementation (add-model Steps 1-5.5) + +### Step 1: Category = `misc` +- Input is a set of positive integers + bounds, not graph/formula/set/algebraic +- File: `src/models/misc/sum_of_squares_partition.rs` + +### Step 1.5: Size getters +- `num_elements()` -> |A| (number of elements) +- `num_groups()` -> K (number of groups) +- Complexity: `num_groups^num_elements` (brute-force K^n) + +### Step 2: Implement the model +- Struct: `SumOfSquaresPartition { sizes: Vec, num_groups: usize, bound: i64 }` +- No type parameters (concrete i64 per issue comments) +- `Problem` trait: NAME = "SumOfSquaresPartition", Metric = bool +- `dims()`: `vec![num_groups; sizes.len()]` — each element assigned to group 0..K-1 +- `evaluate()`: partition elements by group, compute sum of each group, square and sum, + check <= bound +- `SatisfactionProblem` marker trait +- `variant()`: `variant_params![]` (no type params) +- Constructor validates sizes are positive (> 0) +- ProblemSchemaEntry with fields: sizes (Vec), num_groups (usize), bound (i64) + +### Step 2.5: Register variant complexity +```rust +crate::declare_variants! { + default sat SumOfSquaresPartition => "num_groups^num_elements", +} +``` + +### Step 3: Register the model +- `src/models/misc/mod.rs`: add `mod sum_of_squares_partition;` and `pub use` +- `src/models/mod.rs`: add `SumOfSquaresPartition` to misc re-exports + +### Step 4: CLI discovery +- `problemreductions-cli/src/problem_name.rs`: add lowercase alias in resolve_alias() + ("sumofsquarespartition" => "SumOfSquaresPartition") +- No short alias (not well-established in literature) + +### Step 4.5: CLI creation support +- Add `--num-groups` flag to CreateArgs in `cli.rs` +- Add `all_data_flags_empty` check for `num_groups` +- Add match arm in `create.rs` for "SumOfSquaresPartition": + requires --sizes, --num-groups, --bound +- Add to help table in cli.rs +- Add hint string in `hint_for_problem()` + +### Step 4.6: Canonical model example +- Add builder in `src/example_db/model_builders.rs` via `canonical_model_example_specs()` + in the misc module's `sum_of_squares_partition.rs` +- Instance: sizes=[5,3,8,2,7,1], num_groups=3, bound=240 +- Satisfying config: [0,2,0,1,2,1] -> groups {5,8},{2,1},{3,7} -> sums 13,3,10 -> 169+9+100=278 <= 240? No. + Actually need to verify: partition {8,1},{5,2},{3,7} = groups with sums 9,7,10 -> 81+49+100=230 <= 240. + Config mapping: element indices [0..5] for sizes [5,3,8,2,7,1]: + - a0=5 -> group 1 (A2={5,2}), a1=3 -> group 2 (A3={3,7}), a2=8 -> group 0 (A1={8,1}), + a3=2 -> group 1, a4=7 -> group 2, a5=1 -> group 0 + - Config: [1, 2, 0, 1, 2, 0] +- Sample eval with this satisfying config + +### Step 5: Unit tests +- `src/unit_tests/models/misc/sum_of_squares_partition.rs` +- Tests: creation, evaluation (satisfying/unsatisfying), wrong config, brute force, serialization +- Paper example test + +### Step 5.5: trait_consistency +- Add `check_problem_trait(SumOfSquaresPartition::new(...))` entry +- No test_direction entry (satisfaction problem, not optimization) + +## Batch 2: Paper entry (add-model Step 6) + +### Step 6: Document in paper +- Add display name: `"SumOfSquaresPartition": [Sum of Squares Partition]` +- Add `problem-def("SumOfSquaresPartition")` with: + - Formal definition (satisfaction) + - Background (NP-complete in the strong sense, G&J SP19) + - Example with the canonical instance +- Run `make paper` to verify From d53018bb99089be34f7d055f9d89c19ccca7b13e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 18:35:41 +0800 Subject: [PATCH 2/6] 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 --- docs/paper/reductions.typ | 9 + docs/src/reductions/problem_schemas.json | 21 +++ docs/src/reductions/reduction_graph.json | 11 +- problemreductions-cli/src/cli.rs | 4 + problemreductions-cli/src/commands/create.rs | 31 +++- src/example_db/fixtures/examples.json | 1 + src/lib.rs | 1 + src/models/misc/mod.rs | 4 + src/models/misc/sum_of_squares_partition.rs | 170 +++++++++++++++++ src/models/mod.rs | 1 + .../models/misc/sum_of_squares_partition.rs | 173 ++++++++++++++++++ src/unit_tests/trait_consistency.rs | 4 + 12 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 src/models/misc/sum_of_squares_partition.rs create mode 100644 src/unit_tests/models/misc/sum_of_squares_partition.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ce08d7605..78eab5bf2 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -105,6 +105,7 @@ "PartitionIntoTriangles": [Partition Into Triangles], "FlowShopScheduling": [Flow Shop Scheduling], "MinimumTardinessSequencing": [Minimum Tardiness Sequencing], + "SumOfSquaresPartition": [Sum of Squares Partition], ) // Definition label: "def:" — each definition block must have a matching label @@ -1705,6 +1706,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("ShortestCommonSupersequence") let alpha-size = x.instance.alphabet_size diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 5949a528f..a9a11f22f 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -689,6 +689,27 @@ } ] }, + { + "name": "SumOfSquaresPartition", + "description": "Partition positive integers into K groups minimizing sum of squared group sums, subject to bound J", + "fields": [ + { + "name": "sizes", + "type_name": "Vec", + "description": "Positive integer size s(a) for each element a in A" + }, + { + "name": "num_groups", + "type_name": "usize", + "description": "Number of groups K in the partition" + }, + { + "name": "bound", + "type_name": "i64", + "description": "Upper bound J on the sum of squared group sums" + } + ] + }, { "name": "TravelingSalesman", "description": "Find minimum weight Hamiltonian cycle in a graph (Traveling Salesman Problem)", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index cd80f3f06..4bbde8b14 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -525,6 +525,13 @@ "doc_path": "models/misc/struct.SubsetSum.html", "complexity": "2^(num_elements / 2)" }, + { + "name": "SumOfSquaresPartition", + "variant": {}, + "category": "misc", + "doc_path": "models/misc/struct.SumOfSquaresPartition.html", + "complexity": "num_groups^num_elements" + }, { "name": "TravelingSalesman", "variant": { @@ -1361,7 +1368,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 59, + "source": 60, "target": 12, "overhead": [ { @@ -1376,7 +1383,7 @@ "doc_path": "rules/travelingsalesman_ilp/index.html" }, { - "source": 59, + "source": 60, "target": 49, "overhead": [ { diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index e2dbd5b54..e670461eb 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -227,6 +227,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] @@ -407,6 +408,9 @@ pub struct CreateArgs { /// Alphabet size for SCS (optional; inferred from max symbol + 1 if omitted) #[arg(long)] pub alphabet_size: Option, + /// Number of groups for SumOfSquaresPartition + #[arg(long)] + pub num_groups: Option, } #[derive(clap::Args)] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index db0e6c587..cda1f574a 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -9,7 +9,7 @@ use problemreductions::models::algebraic::{ClosestVectorProblem, BMF}; use problemreductions::models::graph::{GraphPartitioning, HamiltonianPath}; use problemreductions::models::misc::{ BinPacking, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing, - PaintShop, ShortestCommonSupersequence, SubsetSum, + PaintShop, ShortestCommonSupersequence, SubsetSum, SumOfSquaresPartition, }; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; @@ -64,6 +64,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.deadline.is_none() && args.num_processors.is_none() && args.alphabet_size.is_none() + && args.num_groups.is_none() } fn emit_problem_output(output: &ProblemJsonOutput, out: &OutputConfig) -> Result<()> { @@ -248,6 +249,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", "ShortestCommonSupersequence" => "--strings \"0,1,2;1,2,0\" --bound 4", _ => "", } @@ -634,6 +636,33 @@ 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::new(sizes, num_groups, bound))?, + resolved_variant.clone(), + ) + } + // PaintShop "PaintShop" => { let seq_str = args.sequence.as_deref().ok_or_else(|| { diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index 83015b13d..3305bb25d 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -31,6 +31,7 @@ {"problem":"ShortestCommonSupersequence","variant":{},"instance":{"alphabet_size":3,"bound":4,"strings":[[0,1,2],[1,0,2]]},"samples":[{"config":[1,0,1,2],"metric":true}],"optimal":[{"config":[0,1,0,2],"metric":true},{"config":[1,0,1,2],"metric":true}]}, {"problem":"SpinGlass","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"couplings":[1,1,1,1,1,1,1],"fields":[0,0,0,0,0],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,2,null],[3,4,null],[0,3,null],[1,3,null],[1,4,null],[2,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}}},"samples":[{"config":[1,0,1,1,0],"metric":{"Valid":-3}}],"optimal":[{"config":[0,0,1,1,0],"metric":{"Valid":-3}},{"config":[0,1,0,0,1],"metric":{"Valid":-3}},{"config":[0,1,0,1,0],"metric":{"Valid":-3}},{"config":[0,1,1,1,0],"metric":{"Valid":-3}},{"config":[1,0,0,0,1],"metric":{"Valid":-3}},{"config":[1,0,1,0,1],"metric":{"Valid":-3}},{"config":[1,0,1,1,0],"metric":{"Valid":-3}},{"config":[1,1,0,0,1],"metric":{"Valid":-3}}]}, {"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":"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}}]} ], "rules": [ diff --git a/src/lib.rs b/src/lib.rs index 64c77b6fc..464f99ff2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,6 +57,7 @@ pub mod prelude { pub use crate::models::misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, PaintShop, ShortestCommonSupersequence, SubsetSum, + SumOfSquaresPartition, }; pub use crate::models::set::{ExactCoverBy3Sets, MaximumSetPacking, MinimumSetCovering}; diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index cc96aa83e..6500e5ba0 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -10,6 +10,7 @@ //! - [`PaintShop`]: Minimize color switches in paint shop scheduling //! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length //! - [`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; @@ -20,6 +21,7 @@ mod minimum_tardiness_sequencing; pub(crate) mod paintshop; pub(crate) mod shortest_common_supersequence; mod subset_sum; +pub(crate) mod sum_of_squares_partition; pub use bin_packing::BinPacking; pub use factoring::Factoring; @@ -30,6 +32,7 @@ pub use minimum_tardiness_sequencing::MinimumTardinessSequencing; pub use paintshop::PaintShop; pub use shortest_common_supersequence::ShortestCommonSupersequence; pub use subset_sum::SubsetSum; +pub use sum_of_squares_partition::SumOfSquaresPartition; #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { @@ -38,5 +41,6 @@ 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, Deserialize)] +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 { + /// Create a new SumOfSquaresPartition instance. + /// + /// # Panics + /// + /// Panics if any size is not positive (must be > 0), if `num_groups` is 0, + /// or if `num_groups` exceeds the number of elements. + pub fn new(sizes: Vec, num_groups: usize, bound: i64) -> Self { + assert!( + sizes.iter().all(|&s| s > 0), + "All sizes must be positive (> 0)" + ); + assert!(num_groups > 0, "Number of groups must be positive"); + assert!( + num_groups <= sizes.len(), + "Number of groups must not exceed number of elements" + ); + Self { + sizes, + num_groups, + bound, + } + } + + /// 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). + pub fn sum_of_squares(&self, config: &[usize]) -> Option { + if config.len() != self.sizes.len() { + return None; + } + let mut group_sums = vec![0i64; self.num_groups]; + for (i, &g) in config.iter().enumerate() { + if g >= self.num_groups { + return None; + } + group_sums[g] += self.sizes[i]; + } + Some(group_sums.iter().map(|&s| s * s).sum()) + } +} + +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 94e95d0ca..60312a90e 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -21,5 +21,6 @@ pub use graph::{ pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, PaintShop, ShortestCommonSupersequence, SubsetSum, + SumOfSquaresPartition, }; pub use set::{ExactCoverBy3Sets, MaximumSetPacking, MinimumSetCovering}; 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 000000000..53b10207c --- /dev/null +++ b/src/unit_tests/models/misc/sum_of_squares_partition.rs @@ -0,0 +1,173 @@ +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_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); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 122362efa..fad74f6c7 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -138,6 +138,10 @@ fn test_all_problems_implement_trait_correctly() { &MinimumTardinessSequencing::new(3, vec![2, 3, 1], vec![(0, 2)]), "MinimumTardinessSequencing", ); + check_problem_trait( + &SumOfSquaresPartition::new(vec![5, 3, 8, 2, 7, 1], 3, 240), + "SumOfSquaresPartition", + ); } #[test] From 9e1cd0db5c2aad988e03286eeb8dc21e8a079397 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 18:35:46 +0800 Subject: [PATCH 3/6] chore: remove plan file after implementation --- .../2026-03-16-sum-of-squares-partition.md | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 docs/plans/2026-03-16-sum-of-squares-partition.md diff --git a/docs/plans/2026-03-16-sum-of-squares-partition.md b/docs/plans/2026-03-16-sum-of-squares-partition.md deleted file mode 100644 index 702efeb19..000000000 --- a/docs/plans/2026-03-16-sum-of-squares-partition.md +++ /dev/null @@ -1,85 +0,0 @@ -# Plan: Add SumOfSquaresPartition Model (#403) - -## Summary - -Implement the SumOfSquaresPartition satisfaction problem (Garey & Johnson SP19). -Given a finite set A of positive integers, K groups, and bound J, determine whether A -can be partitioned into K disjoint groups such that the sum of squared group sums <= J. - -## Batch 1: Implementation (add-model Steps 1-5.5) - -### Step 1: Category = `misc` -- Input is a set of positive integers + bounds, not graph/formula/set/algebraic -- File: `src/models/misc/sum_of_squares_partition.rs` - -### Step 1.5: Size getters -- `num_elements()` -> |A| (number of elements) -- `num_groups()` -> K (number of groups) -- Complexity: `num_groups^num_elements` (brute-force K^n) - -### Step 2: Implement the model -- Struct: `SumOfSquaresPartition { sizes: Vec, num_groups: usize, bound: i64 }` -- No type parameters (concrete i64 per issue comments) -- `Problem` trait: NAME = "SumOfSquaresPartition", Metric = bool -- `dims()`: `vec![num_groups; sizes.len()]` — each element assigned to group 0..K-1 -- `evaluate()`: partition elements by group, compute sum of each group, square and sum, - check <= bound -- `SatisfactionProblem` marker trait -- `variant()`: `variant_params![]` (no type params) -- Constructor validates sizes are positive (> 0) -- ProblemSchemaEntry with fields: sizes (Vec), num_groups (usize), bound (i64) - -### Step 2.5: Register variant complexity -```rust -crate::declare_variants! { - default sat SumOfSquaresPartition => "num_groups^num_elements", -} -``` - -### Step 3: Register the model -- `src/models/misc/mod.rs`: add `mod sum_of_squares_partition;` and `pub use` -- `src/models/mod.rs`: add `SumOfSquaresPartition` to misc re-exports - -### Step 4: CLI discovery -- `problemreductions-cli/src/problem_name.rs`: add lowercase alias in resolve_alias() - ("sumofsquarespartition" => "SumOfSquaresPartition") -- No short alias (not well-established in literature) - -### Step 4.5: CLI creation support -- Add `--num-groups` flag to CreateArgs in `cli.rs` -- Add `all_data_flags_empty` check for `num_groups` -- Add match arm in `create.rs` for "SumOfSquaresPartition": - requires --sizes, --num-groups, --bound -- Add to help table in cli.rs -- Add hint string in `hint_for_problem()` - -### Step 4.6: Canonical model example -- Add builder in `src/example_db/model_builders.rs` via `canonical_model_example_specs()` - in the misc module's `sum_of_squares_partition.rs` -- Instance: sizes=[5,3,8,2,7,1], num_groups=3, bound=240 -- Satisfying config: [0,2,0,1,2,1] -> groups {5,8},{2,1},{3,7} -> sums 13,3,10 -> 169+9+100=278 <= 240? No. - Actually need to verify: partition {8,1},{5,2},{3,7} = groups with sums 9,7,10 -> 81+49+100=230 <= 240. - Config mapping: element indices [0..5] for sizes [5,3,8,2,7,1]: - - a0=5 -> group 1 (A2={5,2}), a1=3 -> group 2 (A3={3,7}), a2=8 -> group 0 (A1={8,1}), - a3=2 -> group 1, a4=7 -> group 2, a5=1 -> group 0 - - Config: [1, 2, 0, 1, 2, 0] -- Sample eval with this satisfying config - -### Step 5: Unit tests -- `src/unit_tests/models/misc/sum_of_squares_partition.rs` -- Tests: creation, evaluation (satisfying/unsatisfying), wrong config, brute force, serialization -- Paper example test - -### Step 5.5: trait_consistency -- Add `check_problem_trait(SumOfSquaresPartition::new(...))` entry -- No test_direction entry (satisfaction problem, not optimization) - -## Batch 2: Paper entry (add-model Step 6) - -### Step 6: Document in paper -- Add display name: `"SumOfSquaresPartition": [Sum of Squares Partition]` -- Add `problem-def("SumOfSquaresPartition")` with: - - Formal definition (satisfaction) - - Background (NP-complete in the strong sense, G&J SP19) - - Example with the canonical instance -- Run `make paper` to verify From acb12fd23060f17be33530fe549aa2047b3ec1d1 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 21:54:40 +0800 Subject: [PATCH 4/6] fix: address PR #663 review comments --- docs/paper/reductions.typ | 2 +- src/models/misc/sum_of_squares_partition.rs | 78 ++++++++++++++----- .../models/misc/sum_of_squares_partition.rs | 57 ++++++++++++++ 3 files changed, 115 insertions(+), 22 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a3c8724e7..66a2075d1 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -1747,7 +1747,7 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS #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$). + 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. ] diff --git a/src/models/misc/sum_of_squares_partition.rs b/src/models/misc/sum_of_squares_partition.rs index 810f4116e..8f885073c 100644 --- a/src/models/misc/sum_of_squares_partition.rs +++ b/src/models/misc/sum_of_squares_partition.rs @@ -7,7 +7,8 @@ use crate::registry::{FieldInfo, ProblemSchemaEntry}; use crate::traits::{Problem, SatisfactionProblem}; -use serde::{Deserialize, Serialize}; +use serde::de::Error; +use serde::{Deserialize, Deserializer, Serialize}; inventory::submit! { ProblemSchemaEntry { @@ -52,7 +53,7 @@ inventory::submit! { /// let solution = solver.find_satisfying(&problem); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct SumOfSquaresPartition { /// Positive integer sizes for each element. sizes: Vec, @@ -63,27 +64,39 @@ pub struct SumOfSquaresPartition { } 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(()) + } + + 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, - /// or if `num_groups` exceeds the number of elements. + /// if `num_groups` exceeds the number of elements, or if `bound` is negative. pub fn new(sizes: Vec, num_groups: usize, bound: i64) -> Self { - assert!( - sizes.iter().all(|&s| s > 0), - "All sizes must be positive (> 0)" - ); - assert!(num_groups > 0, "Number of groups must be positive"); - assert!( - num_groups <= sizes.len(), - "Number of groups must not exceed number of elements" - ); - Self { - sizes, - num_groups, - bound, - } + Self::try_new(sizes, num_groups, bound).unwrap_or_else(|message| panic!("{message}")) } /// Returns the element sizes. @@ -109,19 +122,42 @@ impl SumOfSquaresPartition { /// 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). + /// 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![0i64; self.num_groups]; + 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] += self.sizes[i]; + group_sums[g] = group_sums[g].checked_add(i128::from(self.sizes[i]))?; } - Some(group_sums.iter().map(|&s| s * s).sum()) + 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) } } diff --git a/src/unit_tests/models/misc/sum_of_squares_partition.rs b/src/unit_tests/models/misc/sum_of_squares_partition.rs index 53b10207c..f0defb70d 100644 --- a/src/unit_tests/models/misc/sum_of_squares_partition.rs +++ b/src/unit_tests/models/misc/sum_of_squares_partition.rs @@ -125,6 +125,57 @@ fn test_sum_of_squares_partition_serialization() { 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 @@ -171,3 +222,9 @@ fn test_sum_of_squares_partition_zero_groups_panics() { 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); +} From f93bae2fbb4a9b4a894854d9843e357300490526 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 22:05:55 +0800 Subject: [PATCH 5/6] fix: address PR #663 review feedback --- docs/src/reductions/reduction_graph.json | 4 +- problemreductions-cli/src/commands/create.rs | 3 +- problemreductions-cli/src/commands/solve.rs | 13 ++++- problemreductions-cli/tests/cli_tests.rs | 51 ++++++++++++++++++++ src/models/misc/sum_of_squares_partition.rs | 3 +- 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index 3d1f55782..9c683e248 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -1375,7 +1375,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 60, + "source": 61, "target": 12, "overhead": [ { @@ -1390,7 +1390,7 @@ "doc_path": "rules/travelingsalesman_ilp/index.html" }, { - "source": 60, + "source": 61, "target": 49, "overhead": [ { diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 7d33f38b3..f1a8583da 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -674,7 +674,8 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { })?; let sizes: Vec = util::parse_comma_list(sizes_str)?; ( - ser(SumOfSquaresPartition::new(sizes, num_groups, bound))?, + ser(SumOfSquaresPartition::try_new(sizes, num_groups, bound) + .map_err(anyhow::Error::msg)?)?, resolved_variant.clone(), ) } diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index bdbc1db66..78c16224e 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -29,6 +29,15 @@ fn parse_input(path: &Path) -> Result { } } +fn with_bruteforce_hint(err: anyhow::Error) -> anyhow::Error { + let message = err.to_string(); + if message.starts_with("No reduction path from ") && message.ends_with(" to ILP") { + anyhow::anyhow!("{message}\n\nTry `--solver brute-force`.") + } else { + err + } +} + pub fn solve(input: &Path, solver_name: &str, timeout: u64, out: &OutputConfig) -> Result<()> { if solver_name != "brute-force" && solver_name != "ilp" { anyhow::bail!( @@ -97,7 +106,7 @@ fn solve_problem( result } "ilp" => { - let result = problem.solve_with_ilp()?; + let result = problem.solve_with_ilp().map_err(with_bruteforce_hint)?; let solver_desc = if name == "ILP" { "ilp".to_string() } else { @@ -139,7 +148,7 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig) // 2. Solve the target problem let target_result = match solver_name { "brute-force" => target.solve_brute_force()?, - "ilp" => target.solve_with_ilp()?, + "ilp" => target.solve_with_ilp().map_err(with_bruteforce_hint)?, _ => unreachable!(), }; diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 3855045f6..f29eb5454 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -688,6 +688,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_then_evaluate() { // Create a problem @@ -2446,6 +2469,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_pipe_to_evaluate() { // pred create MIS --graph 0-1,1-2 | pred evaluate - --config 1,0,1 diff --git a/src/models/misc/sum_of_squares_partition.rs b/src/models/misc/sum_of_squares_partition.rs index 8f885073c..508efa0d2 100644 --- a/src/models/misc/sum_of_squares_partition.rs +++ b/src/models/misc/sum_of_squares_partition.rs @@ -80,7 +80,8 @@ impl SumOfSquaresPartition { Ok(()) } - fn try_new(sizes: Vec, num_groups: usize, bound: i64) -> Result { + /// 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, From cb5ad1bbc7e7114cc1370f3191253fa3c2f86410 Mon Sep 17 00:00:00 2001 From: zazabap Date: Wed, 18 Mar 2026 10:00:36 +0000 Subject: [PATCH 6/6] fix: add missing num_groups field to empty_args() in CLI tests Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/commands/create.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 643b890ae..56da8a970 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -3482,6 +3482,7 @@ mod tests { schedules: None, requirements: None, num_workers: None, + num_groups: None, } }