From 7ab8da09048da7deb4ffae1cebb417337f4a8bad Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
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<i64>, 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<i64>), 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 <cacate0129@gmail.com>
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 <noreply@anthropic.com>
---
 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:<ProblemName>" — 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<i64>",
+        "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<usize>,
+    /// Number of groups for SumOfSquaresPartition
+    #[arg(long)]
+    pub num_groups: Option<usize>,
 }
 
 #[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<i64> = 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<crate::example_db::specs::ModelExampleSpec> {
@@ -38,5 +41,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::M
     specs.extend(paintshop::canonical_model_example_specs());
     specs.extend(shortest_common_supersequence::canonical_model_example_specs());
     specs.extend(minimum_tardiness_sequencing::canonical_model_example_specs());
+    specs.extend(sum_of_squares_partition::canonical_model_example_specs());
     specs
 }
diff --git a/src/models/misc/sum_of_squares_partition.rs b/src/models/misc/sum_of_squares_partition.rs
new file mode 100644
index 000000000..810f4116e
--- /dev/null
+++ b/src/models/misc/sum_of_squares_partition.rs
@@ -0,0 +1,170 @@
+//! Sum of Squares Partition problem implementation.
+//!
+//! Given a finite set of positive integers, K groups, and a bound J,
+//! determine whether the set can be partitioned into K groups such that
+//! the sum of squared group sums is at most J.
+//! NP-complete in the strong sense (Garey & Johnson, SP19).
+
+use crate::registry::{FieldInfo, ProblemSchemaEntry};
+use crate::traits::{Problem, SatisfactionProblem};
+use serde::{Deserialize, Serialize};
+
+inventory::submit! {
+    ProblemSchemaEntry {
+        name: "SumOfSquaresPartition",
+        display_name: "Sum of Squares Partition",
+        aliases: &[],
+        dimensions: &[],
+        module_path: module_path!(),
+        description: "Partition positive integers into K groups minimizing sum of squared group sums, subject to bound J",
+        fields: &[
+            FieldInfo { name: "sizes", type_name: "Vec<i64>", 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<i64>,
+    /// 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<i64>, 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<i64> {
+        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<usize> {
+        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<crate::example_db::specs::ModelExampleSpec> {
+    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!(
+        <SumOfSquaresPartition as Problem>::NAME,
+        "SumOfSquaresPartition"
+    );
+    assert_eq!(<SumOfSquaresPartition as Problem>::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 <cacate0129@gmail.com>
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<i64>, 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<i64>), 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 <cacate0129@gmail.com>
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<i64>,
@@ -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<i64>, num_groups: usize, bound: i64) -> Result<Self, String> {
+        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<i64>, 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<i64> {
         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<i64>,
+    num_groups: usize,
+    bound: i64,
+}
+
+impl<'de> Deserialize<'de> for SumOfSquaresPartition {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    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::<SumOfSquaresPartition>(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 <cacate0129@gmail.com>
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<i64> = 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<SolveInput> {
     }
 }
 
+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<i64>, num_groups: usize, bound: i64) -> Result<Self, String> {
+    /// Create a new SumOfSquaresPartition instance, returning validation errors.
+    pub fn try_new(sizes: Vec<i64>, num_groups: usize, bound: i64) -> Result<Self, String> {
         Self::validate_inputs(&sizes, num_groups, bound)?;
         Ok(Self {
             sizes,

From cb5ad1bbc7e7114cc1370f3191253fa3c2f86410 Mon Sep 17 00:00:00 2001
From: zazabap <sweynan@icloud.com>
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) <noreply@anthropic.com>
---
 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,
         }
     }
 
