From 106f7179ece9d499f7dab3f8d7ba2f0dff69451b Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 18:56:22 +0800 Subject: [PATCH 1/5] Add plan for #210: [Model] Partition --- docs/plans/2026-03-16-partition.md | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/plans/2026-03-16-partition.md diff --git a/docs/plans/2026-03-16-partition.md b/docs/plans/2026-03-16-partition.md new file mode 100644 index 000000000..0a3256873 --- /dev/null +++ b/docs/plans/2026-03-16-partition.md @@ -0,0 +1,76 @@ +# Plan: Implement Partition Model (#210) + +## Context + +Issue #210 adds the **Partition** problem — a classical NP-complete satisfaction problem (Karp #20, Garey & Johnson SP12). Given a set of positive integers, decide whether it can be split into two subsets of equal sum. + +- **Type:** Satisfaction problem (`Metric = bool`, `SatisfactionProblem`) +- **Schema:** `Partition { sizes: Vec }` — no type parameters +- **Getter:** `num_elements()` = `sizes.len()` +- **Complexity:** `2^(num_elements / 2)` — Schroeppel–Shamir (1981) meet-in-the-middle +- **Category:** `misc` (unique input structure, closest to SubsetSum) +- **Solvers:** BruteForce (enumerate 2^n subsets) + ILP (binary vars, equality constraint) + +## Batch 1: Implementation (Steps 1–5.5) + +### Step 1: Create model file `src/models/misc/partition.rs` + +Follow `SubsetSum` as the primary reference (same category, satisfaction, integer sizes). + +- `inventory::submit!` with `ProblemSchemaEntry` — name `"Partition"`, display name `"Partition"`, aliases `&[]`, fields: `sizes` (`Vec`) +- Struct: `#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Partition { sizes: Vec }` +- Constructor: `pub fn new(sizes: Vec) -> Self` — assert all sizes > 0 +- Getters: `pub fn sizes(&self) -> &[u64]`, `pub fn num_elements(&self) -> usize` +- Helper: `pub fn total_sum(&self) -> u64` — sum of all sizes (useful for the half-sum check) +- `Problem` impl: `NAME = "Partition"`, `Metric = bool`, `dims = vec![2; num_elements()]`, `variant = crate::variant_params![]` +- `evaluate`: return false if config length mismatch or any value >= 2; compute sum of `sizes[i]` where `config[i] == 1`; return `sum * 2 == total_sum` (avoids integer division) +- `SatisfactionProblem` marker impl +- `declare_variants! { default sat Partition => "2^(num_elements / 2)" }` +- Test path link: `#[cfg(test)] #[path = "../../unit_tests/models/misc/partition.rs"] mod tests;` + +### Step 2: Register in module hierarchy + +- `src/models/misc/mod.rs`: add `mod partition;` and `pub use partition::Partition;` +- `src/models/mod.rs`: add `Partition` to the `pub use misc::{...}` line + +### Step 3: Add canonical example to `src/example_db/` + +In the `Partition` model file, add a `canonical_model_example_specs()` function (gated on `example-db` feature): +- Example instance: `Partition::new(vec![3, 1, 1, 2, 2, 1])` — the issue's example (n=6, total=10, target half=5) +- Sample configs: `vec![1, 0, 0, 1, 0, 0]` (A'={3,2}, sum=5, satisfying) and `vec![0, 0, 0, 0, 0, 0]` (sum=0, not satisfying) +- Use `satisfaction_example()` helper + +In `src/models/misc/mod.rs`: add `specs.extend(partition::canonical_model_example_specs());` + +### Step 4: Write unit tests `src/unit_tests/models/misc/partition.rs` + +Tests (>95% coverage): +- `test_partition_basic`: create instance, check `num_elements()`, `sizes()`, `total_sum()`, `dims()` +- `test_partition_evaluate_satisfying`: evaluate a known satisfying config +- `test_partition_evaluate_unsatisfying`: evaluate a non-satisfying config +- `test_partition_evaluate_wrong_length`: config with wrong length returns false +- `test_partition_evaluate_invalid_value`: config with value >= 2 returns false +- `test_partition_odd_total`: instance with odd total sum — no satisfying assignment exists +- `test_partition_solver`: BruteForce `find_satisfying` on satisfiable instance +- `test_partition_solver_all`: BruteForce `find_all_satisfying`, verify all solutions evaluate to true +- `test_partition_unsatisfiable`: instance with no solution (odd total or no equal split) +- `test_partition_serialization`: serde round-trip +- `test_partition_single_element`: edge case with 1 element (impossible to partition) +- `test_partition_two_elements`: edge case with 2 equal elements + +### Step 5: Add trait_consistency entry + +In `src/unit_tests/trait_consistency.rs`: add `check_problem_trait(&Partition::new(vec![3, 1, 1, 2, 2, 1]), "Partition");` + +## Batch 2: Paper Entry (Step 6) + +### Step 6: Write paper entry in `docs/paper/reductions.typ` + +- Add `"Partition": [Partition]` to `display-name` dict +- Add `#problem-def("Partition")[def][body]`: + - **Definition:** Given a finite set A with sizes s(a) ∈ Z⁺, determine whether ∃ A' ⊆ A such that Σ_{a∈A'} s(a) = Σ_{a∈A\A'} s(a) + - **Body:** Karp #20, GJ SP12, weakly NP-hard (pseudo-polynomial DP in O(n·B)), best exact algorithm is O*(2^{n/2}) meet-in-the-middle. Related to SubsetSum (special case with target = total/2) + - **Example:** A = {3,1,1,2,2,1}, partition A'={3,2} vs A\A'={1,1,2,1} +- Place after SubsetSum in the paper (related problems grouped together) +- Run `make paper` to verify compilation +- Run `make export-schemas` to regenerate JSON exports From 06e3fd5b0bee6f71d592222a8d7502253863e970 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 19:08:51 +0800 Subject: [PATCH 2/5] feat: implement Partition model for #210 --- docs/paper/reductions.typ | 9 ++ docs/src/reductions/problem_schemas.json | 11 ++ docs/src/reductions/reduction_graph.json | 59 +++++----- src/example_db/fixtures/examples.json | 1 + src/models/misc/mod.rs | 4 + src/models/misc/partition.rs | 134 +++++++++++++++++++++++ src/models/mod.rs | 2 +- src/unit_tests/models/misc/partition.rs | 107 ++++++++++++++++++ src/unit_tests/trait_consistency.rs | 1 + 9 files changed, 301 insertions(+), 27 deletions(-) create mode 100644 src/models/misc/partition.rs create mode 100644 src/unit_tests/models/misc/partition.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ce08d7605..e442bc245 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -96,6 +96,7 @@ "LongestCommonSubsequence": [Longest Common Subsequence], "ExactCoverBy3Sets": [Exact Cover by 3-Sets], "SubsetSum": [Subset Sum], + "Partition": [Partition], "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], "ShortestCommonSupersequence": [Shortest Common Supersequence], @@ -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("Partition")[ + Given a finite set $A = {a_0, dots, a_(n-1)}$ with sizes $s(a_i) in ZZ^+$, determine whether there exists a subset $A' subset.eq A$ such that $sum_(a in A') s(a) = sum_(a in A without A') s(a)$. +][ + One of Karp's 21 NP-complete problems @karp1972, listed as SP12 in Garey & Johnson @garey1979. Partition is the special case of Subset Sum where the target equals half the total sum. Though NP-complete, it is only _weakly_ NP-hard: a dynamic-programming algorithm runs in $O(n dot B_"total")$ pseudo-polynomial time, where $B_"total" = sum_i s(a_i)$. The best known exact algorithm is the $O^*(2^(n slash 2))$ meet-in-the-middle approach of Schroeppel and Shamir (1981). + + *Example.* Let $A = {3, 1, 1, 2, 2, 1}$ ($n = 6$, total sum $= 10$). Setting $A' = {3, 2}$ (indices 0, 3) gives sum $3 + 2 = 5 = 10 slash 2$, and $A without A' = {1, 1, 2, 1}$ also sums to 5. Hence a balanced partition exists. +] + #{ 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..4b3ade242 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -525,6 +525,17 @@ } ] }, + { + "name": "Partition", + "description": "Determine whether a multiset of positive integers can be partitioned into two subsets of equal sum", + "fields": [ + { + "name": "sizes", + "type_name": "Vec", + "description": "Positive integer size for each element" + } + ] + }, { "name": "PartitionIntoTriangles", "description": "Partition vertices into triangles (K3 subgraphs)", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index cd80f3f06..c1ea93c26 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -429,6 +429,13 @@ "doc_path": "models/misc/struct.PaintShop.html", "complexity": "2^num_cars" }, + { + "name": "Partition", + "variant": {}, + "category": "misc", + "doc_path": "models/misc/struct.Partition.html", + "complexity": "2^(num_elements / 2)" + }, { "name": "PartitionIntoTriangles", "variant": { @@ -569,7 +576,7 @@ }, { "source": 4, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -629,7 +636,7 @@ }, { "source": 12, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -670,7 +677,7 @@ }, { "source": 19, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -696,7 +703,7 @@ }, { "source": 20, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -722,7 +729,7 @@ }, { "source": 21, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -733,7 +740,7 @@ }, { "source": 21, - "target": 58, + "target": 59, "overhead": [ { "field": "num_elements", @@ -744,7 +751,7 @@ }, { "source": 22, - "target": 51, + "target": 52, "overhead": [ { "field": "num_clauses", @@ -763,7 +770,7 @@ }, { "source": 23, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -789,7 +796,7 @@ }, { "source": 25, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -1104,7 +1111,7 @@ }, { "source": 37, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1219,7 +1226,7 @@ "doc_path": "rules/minimumvertexcover_minimumsetcovering/index.html" }, { - "source": 49, + "source": 50, "target": 12, "overhead": [ { @@ -1234,8 +1241,8 @@ "doc_path": "rules/qubo_ilp/index.html" }, { - "source": 49, - "target": 53, + "source": 50, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1245,7 +1252,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 51, + "source": 52, "target": 4, "overhead": [ { @@ -1260,7 +1267,7 @@ "doc_path": "rules/sat_circuitsat/index.html" }, { - "source": 51, + "source": 52, "target": 16, "overhead": [ { @@ -1275,7 +1282,7 @@ "doc_path": "rules/sat_coloring/index.html" }, { - "source": 51, + "source": 52, "target": 21, "overhead": [ { @@ -1290,7 +1297,7 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 51, + "source": 52, "target": 30, "overhead": [ { @@ -1305,7 +1312,7 @@ "doc_path": "rules/sat_maximumindependentset/index.html" }, { - "source": 51, + "source": 52, "target": 39, "overhead": [ { @@ -1320,8 +1327,8 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 53, - "target": 49, + "source": 54, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1331,7 +1338,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 54, + "source": 55, "target": 25, "overhead": [ { @@ -1346,8 +1353,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 54, - "target": 53, + "source": 55, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1361,7 +1368,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 59, + "source": 60, "target": 12, "overhead": [ { @@ -1376,8 +1383,8 @@ "doc_path": "rules/travelingsalesman_ilp/index.html" }, { - "source": 59, - "target": 49, + "source": 60, + "target": 50, "overhead": [ { "field": "num_vars", diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index 83015b13d..a880365a5 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -25,6 +25,7 @@ {"problem":"MinimumTardinessSequencing","variant":{},"instance":{"deadlines":[2,3,1,4],"num_tasks":4,"precedences":[[0,2]]},"samples":[{"config":[0,0,0,0],"metric":{"Valid":1}}],"optimal":[{"config":[0,0,0,0],"metric":{"Valid":1}},{"config":[0,0,1,0],"metric":{"Valid":1}},{"config":[0,1,0,0],"metric":{"Valid":1}},{"config":[0,2,0,0],"metric":{"Valid":1}},{"config":[1,0,0,0],"metric":{"Valid":1}},{"config":[1,0,1,0],"metric":{"Valid":1}},{"config":[3,0,0,0],"metric":{"Valid":1}}]}, {"problem":"MinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"weights":[1,1,1,1,1]},"samples":[{"config":[1,0,0,1,1],"metric":{"Valid":3}}],"optimal":[{"config":[0,1,1,0,1],"metric":{"Valid":3}},{"config":[0,1,1,1,0],"metric":{"Valid":3}},{"config":[1,0,0,1,1],"metric":{"Valid":3}},{"config":[1,0,1,1,0],"metric":{"Valid":3}}]}, {"problem":"PaintShop","variant":{},"instance":{"car_labels":["A","B","C"],"is_first":[true,true,false,true,false,false],"num_cars":3,"sequence_indices":[0,1,0,2,1,2]},"samples":[{"config":[0,0,1],"metric":{"Valid":2}}],"optimal":[{"config":[0,0,1],"metric":{"Valid":2}},{"config":[0,1,1],"metric":{"Valid":2}},{"config":[1,0,0],"metric":{"Valid":2}},{"config":[1,1,0],"metric":{"Valid":2}}]}, + {"problem":"Partition","variant":{},"instance":{"sizes":[3,1,1,2,2,1]},"samples":[{"config":[1,0,0,1,0,0],"metric":true},{"config":[0,0,0,0,0,0],"metric":false}],"optimal":[{"config":[0,0,0,1,1,1],"metric":true},{"config":[0,0,1,1,1,0],"metric":true},{"config":[0,1,0,1,1,0],"metric":true},{"config":[0,1,1,0,1,1],"metric":true},{"config":[0,1,1,1,0,1],"metric":true},{"config":[1,0,0,0,1,0],"metric":true},{"config":[1,0,0,1,0,0],"metric":true},{"config":[1,0,1,0,0,1],"metric":true},{"config":[1,1,0,0,0,1],"metric":true},{"config":[1,1,1,0,0,0],"metric":true}]}, {"problem":"PartitionIntoTriangles","variant":{"graph":"SimpleGraph"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,2,null],[3,4,null],[3,5,null],[4,5,null],[0,3,null]],"node_holes":[],"nodes":[null,null,null,null,null,null]}}},"samples":[{"config":[0,0,0,1,1,1],"metric":true}],"optimal":[{"config":[0,0,0,1,1,1],"metric":true},{"config":[1,1,1,0,0,0],"metric":true}]}, {"problem":"QUBO","variant":{"weight":"f64"},"instance":{"matrix":[[-1.0,2.0,0.0],[0.0,-1.0,2.0],[0.0,0.0,-1.0]],"num_vars":3},"samples":[{"config":[1,0,1],"metric":{"Valid":-2.0}}],"optimal":[{"config":[1,0,1],"metric":{"Valid":-2.0}}]}, {"problem":"Satisfiability","variant":{},"instance":{"clauses":[{"literals":[1,2]},{"literals":[-1,3]},{"literals":[-2,-3]}],"num_vars":3},"samples":[{"config":[1,0,1],"metric":true}],"optimal":[{"config":[0,1,0],"metric":true},{"config":[1,0,1],"metric":true}]}, diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index cc96aa83e..51179c8d2 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -8,6 +8,7 @@ //! - [`LongestCommonSubsequence`]: Longest Common Subsequence //! - [`MinimumTardinessSequencing`]: Minimize tardy tasks in single-machine scheduling //! - [`PaintShop`]: Minimize color switches in paint shop scheduling +//! - [`Partition`]: Partition a multiset into two equal-sum subsets //! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length //! - [`SubsetSum`]: Find a subset summing to exactly a target value @@ -18,6 +19,7 @@ mod knapsack; mod longest_common_subsequence; mod minimum_tardiness_sequencing; pub(crate) mod paintshop; +pub(crate) mod partition; pub(crate) mod shortest_common_supersequence; mod subset_sum; @@ -28,6 +30,7 @@ pub use knapsack::Knapsack; pub use longest_common_subsequence::LongestCommonSubsequence; pub use minimum_tardiness_sequencing::MinimumTardinessSequencing; pub use paintshop::PaintShop; +pub use partition::Partition; pub use shortest_common_supersequence::ShortestCommonSupersequence; pub use subset_sum::SubsetSum; @@ -36,6 +39,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Positive integer size for each element" }, + ], + } +} + +/// The Partition problem. +/// +/// Given a finite set `A` with `n` positive integer sizes, determine whether +/// there exists a subset `A' ⊆ A` such that `∑_{a ∈ A'} s(a) = ∑_{a ∈ A\A'} s(a)`. +/// +/// # Representation +/// +/// Each element has a binary variable: `x_i = 1` if element `i` is in the +/// second subset, `0` if in the first. The problem is satisfiable iff +/// `∑_{i: x_i=1} sizes[i] = total_sum / 2`. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::misc::Partition; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); +/// let solver = BruteForce::new(); +/// let solution = solver.find_satisfying(&problem); +/// assert!(solution.is_some()); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Partition { + sizes: Vec, +} + +impl Partition { + /// Create a new Partition instance. + /// + /// # Panics + /// + /// Panics if `sizes` is empty or any size is zero. + pub fn new(sizes: Vec) -> Self { + assert!(!sizes.is_empty(), "Partition requires at least one element"); + assert!( + sizes.iter().all(|&s| s > 0), + "All sizes must be positive (> 0)" + ); + Self { sizes } + } + + /// Returns the element sizes. + pub fn sizes(&self) -> &[u64] { + &self.sizes + } + + /// Returns the number of elements. + pub fn num_elements(&self) -> usize { + self.sizes.len() + } + + /// Returns the total sum of all sizes. + pub fn total_sum(&self) -> u64 { + self.sizes.iter().sum() + } +} + +impl Problem for Partition { + const NAME: &'static str = "Partition"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.num_elements()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + if config.len() != self.num_elements() { + return false; + } + if config.iter().any(|&v| v >= 2) { + return false; + } + let selected_sum: u64 = config + .iter() + .enumerate() + .filter(|(_, &x)| x == 1) + .map(|(i, _)| self.sizes[i]) + .sum(); + selected_sum * 2 == self.total_sum() + } +} + +impl SatisfactionProblem for Partition {} + +crate::declare_variants! { + default sat Partition => "2^(num_elements / 2)", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "partition", + build: || { + let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); + crate::example_db::specs::satisfaction_example( + problem, + vec![vec![1, 0, 0, 1, 0, 0], vec![0, 0, 0, 0, 0, 0]], + ) + }, + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/partition.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 94e95d0ca..96f4d1e3c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -20,6 +20,6 @@ pub use graph::{ }; pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, - MinimumTardinessSequencing, PaintShop, ShortestCommonSupersequence, SubsetSum, + MinimumTardinessSequencing, PaintShop, Partition, ShortestCommonSupersequence, SubsetSum, }; pub use set::{ExactCoverBy3Sets, MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/misc/partition.rs b/src/unit_tests/models/misc/partition.rs new file mode 100644 index 000000000..a92ce5cae --- /dev/null +++ b/src/unit_tests/models/misc/partition.rs @@ -0,0 +1,107 @@ +use crate::models::misc::Partition; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; + +#[test] +fn test_partition_basic() { + let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); + assert_eq!(problem.num_elements(), 6); + assert_eq!(problem.sizes(), &[3, 1, 1, 2, 2, 1]); + assert_eq!(problem.total_sum(), 10); + assert_eq!(problem.dims(), vec![2; 6]); +} + +#[test] +fn test_partition_evaluate_satisfying() { + let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); + // A' = {3, 2} (indices 0, 3), sum = 5 = 10/2 + assert!(problem.evaluate(&[1, 0, 0, 1, 0, 0])); +} + +#[test] +fn test_partition_evaluate_unsatisfying() { + let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); + // All in first subset, selected sum = 0 != 5 + assert!(!problem.evaluate(&[0, 0, 0, 0, 0, 0])); + // All in second subset, selected sum = 10 != 5 + assert!(!problem.evaluate(&[1, 1, 1, 1, 1, 1])); +} + +#[test] +fn test_partition_evaluate_wrong_length() { + let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); + assert!(!problem.evaluate(&[1, 0, 0])); + assert!(!problem.evaluate(&[1, 0, 0, 1, 0, 0, 0])); +} + +#[test] +fn test_partition_evaluate_invalid_value() { + let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); + assert!(!problem.evaluate(&[2, 0, 0, 0, 0, 0])); +} + +#[test] +fn test_partition_odd_total() { + // Total = 7 (odd), no equal partition possible + let problem = Partition::new(vec![3, 1, 2, 1]); + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&problem).is_none()); +} + +#[test] +fn test_partition_solver() { + let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); +} + +#[test] +fn test_partition_solver_all() { + let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); + 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_partition_single_element() { + // Single element can never be partitioned equally + let problem = Partition::new(vec![5]); + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&problem).is_none()); +} + +#[test] +fn test_partition_two_equal_elements() { + let problem = Partition::new(vec![4, 4]); + let solver = BruteForce::new(); + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); +} + +#[test] +fn test_partition_serialization() { + let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); + let json = serde_json::to_value(&problem).unwrap(); + let restored: Partition = serde_json::from_value(json).unwrap(); + assert_eq!(restored.sizes(), problem.sizes()); + assert_eq!(restored.num_elements(), problem.num_elements()); +} + +#[test] +#[should_panic(expected = "All sizes must be positive")] +fn test_partition_zero_size_panics() { + Partition::new(vec![3, 0, 1]); +} + +#[test] +#[should_panic(expected = "at least one element")] +fn test_partition_empty_panics() { + Partition::new(vec![]); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 122362efa..0817c6798 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -89,6 +89,7 @@ fn test_all_problems_implement_trait_correctly() { "BicliqueCover", ); check_problem_trait(&Factoring::new(6, 2, 2), "Factoring"); + check_problem_trait(&Partition::new(vec![3, 1, 1, 2, 2, 1]), "Partition"); let circuit = Circuit::new(vec![Assignment::new( vec!["x".to_string()], From 8492128eeb0cf381230dfb7dd1e9ad8e77a4d4e6 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 19:16:03 +0800 Subject: [PATCH 3/5] fix: add Partition to prelude, strengthen solution count test --- src/lib.rs | 2 +- src/unit_tests/models/misc/partition.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 64c77b6fc..543276263 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,7 +56,7 @@ pub mod prelude { }; pub use crate::models::misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, - MinimumTardinessSequencing, PaintShop, ShortestCommonSupersequence, SubsetSum, + MinimumTardinessSequencing, PaintShop, Partition, ShortestCommonSupersequence, SubsetSum, }; pub use crate::models::set::{ExactCoverBy3Sets, MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/misc/partition.rs b/src/unit_tests/models/misc/partition.rs index a92ce5cae..308d27d49 100644 --- a/src/unit_tests/models/misc/partition.rs +++ b/src/unit_tests/models/misc/partition.rs @@ -62,7 +62,8 @@ fn test_partition_solver_all() { let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); let solver = BruteForce::new(); let solutions = solver.find_all_satisfying(&problem); - assert!(!solutions.is_empty()); + // 10 satisfying configs for {3,1,1,2,2,1} with target half-sum 5 + assert_eq!(solutions.len(), 10); for sol in &solutions { assert!(problem.evaluate(sol)); } From f3f9ed21c19e25ab7cb67ab097b7f0f6306532e2 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 19:16:14 +0800 Subject: [PATCH 4/5] chore: remove plan file after implementation --- docs/plans/2026-03-16-partition.md | 76 ------------------------------ 1 file changed, 76 deletions(-) delete mode 100644 docs/plans/2026-03-16-partition.md diff --git a/docs/plans/2026-03-16-partition.md b/docs/plans/2026-03-16-partition.md deleted file mode 100644 index 0a3256873..000000000 --- a/docs/plans/2026-03-16-partition.md +++ /dev/null @@ -1,76 +0,0 @@ -# Plan: Implement Partition Model (#210) - -## Context - -Issue #210 adds the **Partition** problem — a classical NP-complete satisfaction problem (Karp #20, Garey & Johnson SP12). Given a set of positive integers, decide whether it can be split into two subsets of equal sum. - -- **Type:** Satisfaction problem (`Metric = bool`, `SatisfactionProblem`) -- **Schema:** `Partition { sizes: Vec }` — no type parameters -- **Getter:** `num_elements()` = `sizes.len()` -- **Complexity:** `2^(num_elements / 2)` — Schroeppel–Shamir (1981) meet-in-the-middle -- **Category:** `misc` (unique input structure, closest to SubsetSum) -- **Solvers:** BruteForce (enumerate 2^n subsets) + ILP (binary vars, equality constraint) - -## Batch 1: Implementation (Steps 1–5.5) - -### Step 1: Create model file `src/models/misc/partition.rs` - -Follow `SubsetSum` as the primary reference (same category, satisfaction, integer sizes). - -- `inventory::submit!` with `ProblemSchemaEntry` — name `"Partition"`, display name `"Partition"`, aliases `&[]`, fields: `sizes` (`Vec`) -- Struct: `#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Partition { sizes: Vec }` -- Constructor: `pub fn new(sizes: Vec) -> Self` — assert all sizes > 0 -- Getters: `pub fn sizes(&self) -> &[u64]`, `pub fn num_elements(&self) -> usize` -- Helper: `pub fn total_sum(&self) -> u64` — sum of all sizes (useful for the half-sum check) -- `Problem` impl: `NAME = "Partition"`, `Metric = bool`, `dims = vec![2; num_elements()]`, `variant = crate::variant_params![]` -- `evaluate`: return false if config length mismatch or any value >= 2; compute sum of `sizes[i]` where `config[i] == 1`; return `sum * 2 == total_sum` (avoids integer division) -- `SatisfactionProblem` marker impl -- `declare_variants! { default sat Partition => "2^(num_elements / 2)" }` -- Test path link: `#[cfg(test)] #[path = "../../unit_tests/models/misc/partition.rs"] mod tests;` - -### Step 2: Register in module hierarchy - -- `src/models/misc/mod.rs`: add `mod partition;` and `pub use partition::Partition;` -- `src/models/mod.rs`: add `Partition` to the `pub use misc::{...}` line - -### Step 3: Add canonical example to `src/example_db/` - -In the `Partition` model file, add a `canonical_model_example_specs()` function (gated on `example-db` feature): -- Example instance: `Partition::new(vec![3, 1, 1, 2, 2, 1])` — the issue's example (n=6, total=10, target half=5) -- Sample configs: `vec![1, 0, 0, 1, 0, 0]` (A'={3,2}, sum=5, satisfying) and `vec![0, 0, 0, 0, 0, 0]` (sum=0, not satisfying) -- Use `satisfaction_example()` helper - -In `src/models/misc/mod.rs`: add `specs.extend(partition::canonical_model_example_specs());` - -### Step 4: Write unit tests `src/unit_tests/models/misc/partition.rs` - -Tests (>95% coverage): -- `test_partition_basic`: create instance, check `num_elements()`, `sizes()`, `total_sum()`, `dims()` -- `test_partition_evaluate_satisfying`: evaluate a known satisfying config -- `test_partition_evaluate_unsatisfying`: evaluate a non-satisfying config -- `test_partition_evaluate_wrong_length`: config with wrong length returns false -- `test_partition_evaluate_invalid_value`: config with value >= 2 returns false -- `test_partition_odd_total`: instance with odd total sum — no satisfying assignment exists -- `test_partition_solver`: BruteForce `find_satisfying` on satisfiable instance -- `test_partition_solver_all`: BruteForce `find_all_satisfying`, verify all solutions evaluate to true -- `test_partition_unsatisfiable`: instance with no solution (odd total or no equal split) -- `test_partition_serialization`: serde round-trip -- `test_partition_single_element`: edge case with 1 element (impossible to partition) -- `test_partition_two_elements`: edge case with 2 equal elements - -### Step 5: Add trait_consistency entry - -In `src/unit_tests/trait_consistency.rs`: add `check_problem_trait(&Partition::new(vec![3, 1, 1, 2, 2, 1]), "Partition");` - -## Batch 2: Paper Entry (Step 6) - -### Step 6: Write paper entry in `docs/paper/reductions.typ` - -- Add `"Partition": [Partition]` to `display-name` dict -- Add `#problem-def("Partition")[def][body]`: - - **Definition:** Given a finite set A with sizes s(a) ∈ Z⁺, determine whether ∃ A' ⊆ A such that Σ_{a∈A'} s(a) = Σ_{a∈A\A'} s(a) - - **Body:** Karp #20, GJ SP12, weakly NP-hard (pseudo-polynomial DP in O(n·B)), best exact algorithm is O*(2^{n/2}) meet-in-the-middle. Related to SubsetSum (special case with target = total/2) - - **Example:** A = {3,1,1,2,2,1}, partition A'={3,2} vs A\A'={1,1,2,1} -- Place after SubsetSum in the paper (related problems grouped together) -- Run `make paper` to verify compilation -- Run `make export-schemas` to regenerate JSON exports From 7b732b2a70be1afcfd6f041bc9e40208537ac6fe Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 20 Mar 2026 00:23:38 +0800 Subject: [PATCH 5/5] fix: update Partition example spec to new ModelExampleSpec API --- src/models/misc/partition.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/models/misc/partition.rs b/src/models/misc/partition.rs index 4d38095d0..87703d18d 100644 --- a/src/models/misc/partition.rs +++ b/src/models/misc/partition.rs @@ -119,13 +119,9 @@ crate::declare_variants! { pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { id: "partition", - build: || { - let problem = Partition::new(vec![3, 1, 1, 2, 2, 1]); - crate::example_db::specs::satisfaction_example( - problem, - vec![vec![1, 0, 0, 1, 0, 0], vec![0, 0, 0, 0, 0, 0]], - ) - }, + instance: Box::new(Partition::new(vec![3, 1, 1, 2, 2, 1])), + optimal_config: vec![1, 0, 0, 1, 0, 0], + optimal_value: serde_json::json!(true), }] }