From 2c46c64664ed22eddb1f78d31944a7974a04ff4b Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 12 Mar 2026 14:12:56 +0800 Subject: [PATCH 1/7] Add plan for #117: [Model] GraphPartitioning Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-12-graph-partitioning.md | 98 +++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/plans/2026-03-12-graph-partitioning.md diff --git a/docs/plans/2026-03-12-graph-partitioning.md b/docs/plans/2026-03-12-graph-partitioning.md new file mode 100644 index 000000000..cc69177f8 --- /dev/null +++ b/docs/plans/2026-03-12-graph-partitioning.md @@ -0,0 +1,98 @@ +# Plan: Add GraphPartitioning Model (#117) + +## Overview + +Add the GraphPartitioning (Minimum Bisection) problem to the codebase. Given an undirected graph G = (V, E) with |V| = n (even), partition V into two equal halves A, B with |A| = |B| = n/2, minimizing the number of crossing edges. + +## Design Decisions + +- **Name:** `GraphPartitioning` (no prefix, per issue) +- **Type parameters:** Single parameter `G: Graph` (no weight type — unweighted edge counting) +- **Metric:** `SolutionSize` — cut size is an integer count +- **Direction:** `Minimize` +- **Feasibility:** Config is valid iff exactly n/2 vertices are assigned to each side (balanced bisection). If n is odd, all configs are `Invalid`. +- **Complexity:** `"2^num_vertices"` — brute-force bound (no known sub-exponential exact algorithm for general minimum bisection) +- **Solver:** Existing BruteForce works via OptimizationProblem trait +- **Category:** `graph/` (graph input) + +## Tasks + +### Task 1: Implement the model [independent] + +**File:** `src/models/graph/graph_partitioning.rs` + +Follow MaxCut as template, with these differences: +- Single type param `G` (no `W`) +- No edge weights — just count crossing edges +- `evaluate()` checks balanced bisection (sum of config == n/2), returns `Invalid` if not balanced or n is odd +- `direction()` returns `Minimize` +- `variant_params![G]` (single param) + +Structure: +1. `inventory::submit!` with `ProblemSchemaEntry` — fields: `[graph: G]` +2. Struct `GraphPartitioning` with field `graph: G` +3. `new(graph)` constructor + `graph()` accessor +4. Size getters: `num_vertices()`, `num_edges()` +5. `Problem` impl with `Metric = SolutionSize`, `dims() = vec![2; n]` +6. `OptimizationProblem` impl with `Value = i32`, `direction() = Minimize` +7. `declare_variants! { GraphPartitioning => "2^num_vertices" }` +8. `#[cfg(test)] #[path = "..."] mod tests;` + +### Task 2: Register the model [depends on Task 1] + +**Files to update:** +1. `src/models/graph/mod.rs` — add `pub(crate) mod graph_partitioning;` and `pub use graph_partitioning::GraphPartitioning;` +2. `src/models/mod.rs` — add `GraphPartitioning` to the graph re-export line + +### Task 3: Register in CLI [depends on Task 1] + +**Files to update:** + +1. `problemreductions-cli/src/dispatch.rs`: + - `load_problem()`: add `"GraphPartitioning" => deser_opt::>(data)` + - `serialize_any_problem()`: add `"GraphPartitioning" => try_ser::>(any)` + - Add import: `use problemreductions::models::graph::GraphPartitioning;` + +2. `problemreductions-cli/src/problem_name.rs`: + - `resolve_alias()`: add `"graphpartitioning" => "GraphPartitioning".to_string()` + +3. `problemreductions-cli/src/commands/create.rs`: + - `example_for()`: add `"GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3"` + - Main `create()` match: add a new arm for `"GraphPartitioning"` that parses `--graph` and constructs with `GraphPartitioning::new(graph)` + - Random generation arm: add `"GraphPartitioning"` with random graph + `variant_map(&[("graph", "SimpleGraph")])` + - Add import for `GraphPartitioning` + +### Task 4: Write unit tests [depends on Task 1] + +**File:** `src/unit_tests/models/graph/graph_partitioning.rs` + +Tests to write: +- `test_graphpartitioning_basic` — construct instance, verify dims, evaluate valid/invalid configs +- `test_graphpartitioning_direction` — verify `Minimize` +- `test_graphpartitioning_serialization` — round-trip serde +- `test_graphpartitioning_solver` — brute-force finds optimal partition matching issue example +- `test_graphpartitioning_odd_vertices` — all configs Invalid when n is odd +- `test_graphpartitioning_unbalanced_invalid` — non-balanced partitions return Invalid + +Use issue example: 6 vertices, edges (0,1),(0,2),(1,2),(1,3),(2,3),(2,4),(3,4),(3,5),(4,5), optimal cut = 3. + +Also register in `src/unit_tests/models/graph/mod.rs`. + +### Task 5: Document in paper [depends on Task 1] + +**File:** `docs/paper/reductions.typ` + +1. Add to `display-name` dict: `"GraphPartitioning": [Graph Partitioning]` +2. Add `#problem-def("GraphPartitioning")[...]` with: + - Formal definition of minimum bisection + - Background on VLSI, parallel computing applications + - Example with 6-vertex graph and CeTZ visualization + - Algorithm list (brute-force) + +### Task 6: Verify [depends on Tasks 1-5] + +```bash +make fmt +make clippy +make test +``` From fd26bb56234e84d8e522b38d3b2fb1b32e837b5c Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 12 Mar 2026 14:25:14 +0800 Subject: [PATCH 2/7] Implement #117: Add GraphPartitioning model - Add GraphPartitioning (Minimum Bisection) problem model - Single type param G (unweighted, counts crossing edges) - Balanced bisection: requires |A| = |B| = n/2 (even n) - Direction: Minimize - Register in CLI (dispatch, aliases, create command) - Add unit tests (11 tests covering all cases) - Add problem-def entry in paper with CeTZ visualization Co-Authored-By: Claude Opus 4.6 --- docs/paper/reductions.typ | 52 ++++++ problemreductions-cli/src/commands/create.rs | 26 +++ problemreductions-cli/src/dispatch.rs | 2 + problemreductions-cli/src/problem_name.rs | 1 + src/lib.rs | 2 +- src/models/graph/graph_partitioning.rs | 139 ++++++++++++++ src/models/graph/mod.rs | 3 + src/models/mod.rs | 5 +- .../models/graph/graph_partitioning.rs | 169 ++++++++++++++++++ 9 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 src/models/graph/graph_partitioning.rs create mode 100644 src/unit_tests/models/graph/graph_partitioning.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 915878827..931de158c 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -29,6 +29,7 @@ "MaximumIndependentSet": [Maximum Independent Set], "MinimumVertexCover": [Minimum Vertex Cover], "MaxCut": [Max-Cut], + "GraphPartitioning": [Graph Partitioning], "KColoring": [$k$-Coloring], "MinimumDominatingSet": [Minimum Dominating Set], "MaximumMatching": [Maximum Matching], @@ -379,6 +380,57 @@ Max-Cut is NP-hard on general graphs @barahona1982 but polynomial-time solvable caption: [The house graph with max cut $S = {v_0, v_3}$ (blue) vs $overline(S) = {v_1, v_2, v_4}$ (white). Cut edges shown in bold blue; 5 of 6 edges are cut.], ) ] +#problem-def("GraphPartitioning")[ + Given an undirected graph $G = (V, E)$ with $|V| = n$ (even), find a partition of $V$ into two disjoint sets $A$ and $B$ with $|A| = |B| = n slash 2$ that minimizes the number of edges crossing the partition: + $ "cut"(A, B) = |{(u, v) in E : u in A, v in B}|. $ +][ +Graph Partitioning is a core NP-hard problem arising in VLSI design, parallel computing, and scientific simulation, where balanced workload distribution with minimal communication is essential. Closely related to Max-Cut (which _maximizes_ rather than _minimizes_ the cut) and to the Ising Spin Glass model. NP-completeness was proved by Garey, Johnson and Stockmeyer (1976). Arora, Rao and Vazirani (2009) gave an $O(sqrt(log n))$-approximation algorithm. Standard partitioning tools include METIS, KaHIP, and Scotch. + +*Example.* Consider the graph $G$ with $n = 6$ vertices and 9 edges: $(v_0, v_1)$, $(v_0, v_2)$, $(v_1, v_2)$, $(v_1, v_3)$, $(v_2, v_3)$, $(v_2, v_4)$, $(v_3, v_4)$, $(v_3, v_5)$, $(v_4, v_5)$. The optimal balanced partition is $A = {v_0, v_1, v_2}$, $B = {v_3, v_4, v_5}$, with cut value 3: the crossing edges are $(v_1, v_3)$, $(v_2, v_3)$, $(v_2, v_4)$. All other balanced partitions yield a cut of at least 3. + +#figure( + canvas(length: 1cm, { + // 6-vertex layout: two columns of 3 + let verts = ( + (0, 2), // v0: top-left + (0, 1), // v1: mid-left + (0, 0), // v2: bottom-left + (2.5, 2), // v3: top-right + (2.5, 1), // v4: mid-right + (2.5, 0), // v5: bottom-right + ) + let edges = ((0,1),(0,2),(1,2),(1,3),(2,3),(2,4),(3,4),(3,5),(4,5)) + let side-a = (0, 1, 2) + let cut-edges = edges.filter(e => side-a.contains(e.at(0)) != side-a.contains(e.at(1))) + // Draw edges + for (u, v) in edges { + let crossing = cut-edges.any(e => (e.at(0) == u and e.at(1) == v) or (e.at(0) == v and e.at(1) == u)) + g-edge(verts.at(u), verts.at(v), + stroke: if crossing { 2pt + graph-colors.at(1) } else { 1pt + luma(180) }) + } + // Draw partition regions + import draw: * + on-layer(-1, { + rect((-0.5, -0.5), (0.5, 2.5), + fill: graph-colors.at(0).transparentize(90%), + stroke: (dash: "dashed", paint: graph-colors.at(0), thickness: 0.8pt)) + content((0, 2.8), text(8pt, fill: graph-colors.at(0))[$A$]) + rect((2.0, -0.5), (3.0, 2.5), + fill: graph-colors.at(1).transparentize(90%), + stroke: (dash: "dashed", paint: graph-colors.at(1), thickness: 0.8pt)) + content((2.5, 2.8), text(8pt, fill: graph-colors.at(1))[$B$]) + }) + // Draw nodes + for (k, pos) in verts.enumerate() { + let in-a = side-a.contains(k) + g-node(pos, name: "v" + str(k), + fill: if in-a { graph-colors.at(0) } else { graph-colors.at(1) }, + label: text(fill: white)[$v_#k$]) + } + }), + caption: [Graph with $n = 6$ vertices partitioned into $A = {v_0, v_1, v_2}$ (blue) and $B = {v_3, v_4, v_5}$ (red). The 3 crossing edges $(v_1, v_3)$, $(v_2, v_3)$, $(v_2, v_4)$ are shown in bold red; internal edges are gray.], +) +] #problem-def("KColoring")[ Given $G = (V, E)$ and $k$ colors, find $c: V -> {1, ..., k}$ minimizing $|{(u, v) in E : c(u) = c(v)}|$. ][ diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 418bc520f..0fee94fa9 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -5,6 +5,7 @@ use crate::problem_name::{parse_problem_spec, resolve_variant}; use crate::util; use anyhow::{bail, Context, Result}; use problemreductions::models::algebraic::{ClosestVectorProblem, BMF}; +use problemreductions::models::graph::GraphPartitioning; use problemreductions::models::misc::{BinPacking, PaintShop}; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; @@ -74,6 +75,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { Some("UnitDiskGraph") => "--positions \"0,0;1,0;0.5,0.8\" --radius 1.5", _ => "--graph 0-1,1-2,2-3 --weights 1,1,1,1", }, + "GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3", "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } @@ -193,6 +195,19 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { create_vertex_weight_problem(args, canonical, graph_type, &resolved_variant)? } + // Graph partitioning (graph only, no weights) + "GraphPartitioning" => { + let (graph, _) = parse_graph(args).map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create GraphPartitioning --graph 0-1,1-2,2-3,0-2,1-3,0-3" + ) + })?; + ( + ser(GraphPartitioning::new(graph))?, + resolved_variant.clone(), + ) + } + // Graph problems with edge weights "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -874,6 +889,17 @@ fn create_random( } } + // GraphPartitioning (graph only, no weights) + "GraphPartitioning" => { + let edge_prob = args.edge_prob.unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + bail!("--edge-prob must be between 0.0 and 1.0"); + } + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let variant = variant_map(&[("graph", "SimpleGraph")]); + (ser(GraphPartitioning::new(graph))?, variant) + } + // Graph problems with edge weights "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { let edge_prob = args.edge_prob.unwrap_or(0.5); diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 7a8498421..3766a829d 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -210,6 +210,7 @@ pub fn load_problem( "MaximumClique" => deser_opt::>(data), "MaximumMatching" => deser_opt::>(data), "MinimumDominatingSet" => deser_opt::>(data), + "GraphPartitioning" => deser_opt::>(data), "MaxCut" => deser_opt::>(data), "MaximalIS" => deser_opt::>(data), "TravelingSalesman" => deser_opt::>(data), @@ -267,6 +268,7 @@ pub fn serialize_any_problem( "MaximumClique" => try_ser::>(any), "MaximumMatching" => try_ser::>(any), "MinimumDominatingSet" => try_ser::>(any), + "GraphPartitioning" => try_ser::>(any), "MaxCut" => try_ser::>(any), "MaximalIS" => try_ser::>(any), "TravelingSalesman" => try_ser::>(any), diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index acd9b4b59..a1ef83861 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -32,6 +32,7 @@ pub fn resolve_alias(input: &str) -> String { "3sat" => "KSatisfiability".to_string(), "ksat" | "ksatisfiability" => "KSatisfiability".to_string(), "qubo" => "QUBO".to_string(), + "graphpartitioning" => "GraphPartitioning".to_string(), "maxcut" => "MaxCut".to_string(), "spinglass" => "SpinGlass".to_string(), "ilp" => "ILP".to_string(), diff --git a/src/lib.rs b/src/lib.rs index 87b0126a0..580d9f37c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,7 +38,7 @@ pub mod prelude { // Problem types pub use crate::models::algebraic::{BMF, QUBO}; pub use crate::models::formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; - pub use crate::models::graph::{BicliqueCover, SpinGlass}; + pub use crate::models::graph::{BicliqueCover, GraphPartitioning, SpinGlass}; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumVertexCover, TravelingSalesman, diff --git a/src/models/graph/graph_partitioning.rs b/src/models/graph/graph_partitioning.rs new file mode 100644 index 000000000..3a00c783e --- /dev/null +++ b/src/models/graph/graph_partitioning.rs @@ -0,0 +1,139 @@ +//! GraphPartitioning problem implementation. +//! +//! The Graph Partitioning (Minimum Bisection) problem asks for a balanced partition +//! of vertices into two equal halves minimizing the number of crossing edges. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize}; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "GraphPartitioning", + module_path: module_path!(), + description: "Find minimum cut balanced bisection of a graph", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The undirected graph G=(V,E)" }, + ], + } +} + +/// The Graph Partitioning (Minimum Bisection) problem. +/// +/// Given an undirected graph G = (V, E) with |V| = n (even), +/// partition V into two disjoint sets A and B with |A| = |B| = n/2, +/// minimizing the number of edges crossing the partition. +/// +/// # Type Parameters +/// +/// * `G` - The graph type (e.g., `SimpleGraph`) +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::GraphPartitioning; +/// use problemreductions::topology::SimpleGraph; +/// use problemreductions::types::SolutionSize; +/// use problemreductions::{Problem, Solver, BruteForce}; +/// +/// // Square graph: 0-1, 1-2, 2-3, 3-0 +/// let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (3, 0)]); +/// let problem = GraphPartitioning::new(graph); +/// +/// let solver = BruteForce::new(); +/// let solutions = solver.find_all_best(&problem); +/// +/// // Minimum bisection of a 4-cycle: cut = 2 +/// for sol in solutions { +/// let size = problem.evaluate(&sol); +/// assert_eq!(size, SolutionSize::Valid(2)); +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphPartitioning { + /// The underlying graph structure. + graph: G, +} + +impl GraphPartitioning { + /// Create a GraphPartitioning problem from a graph. + /// + /// # Arguments + /// * `graph` - The undirected graph to partition + pub fn new(graph: G) -> Self { + Self { graph } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } +} + +impl Problem for GraphPartitioning +where + G: Graph + crate::variant::VariantParam, +{ + const NAME: &'static str = "GraphPartitioning"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + let n = self.graph.num_vertices(); + // Balanced bisection requires even n + if !n.is_multiple_of(2) { + return SolutionSize::Invalid; + } + // Check balanced: exactly n/2 vertices in partition 1 + let count_ones: usize = config.iter().sum(); + if count_ones != n / 2 { + return SolutionSize::Invalid; + } + // Count crossing edges + let mut cut = 0i32; + for (u, v) in self.graph.edges() { + if config[u] != config[v] { + cut += 1; + } + } + SolutionSize::Valid(cut) + } +} + +impl OptimizationProblem for GraphPartitioning +where + G: Graph + crate::variant::VariantParam, +{ + type Value = i32; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +crate::declare_variants! { + GraphPartitioning => "2^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/graph_partitioning.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 1198f7fbc..2e7cb23e5 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -7,6 +7,7 @@ //! - [`MinimumDominatingSet`]: Minimum dominating set //! - [`MaximumClique`]: Maximum weight clique //! - [`MaxCut`]: Maximum cut on weighted graphs +//! - [`GraphPartitioning`]: Minimum bisection (balanced graph partitioning) //! - [`KColoring`]: K-vertex coloring //! - [`MaximumMatching`]: Maximum weight matching //! - [`TravelingSalesman`]: Traveling Salesman (minimum weight Hamiltonian cycle) @@ -14,6 +15,7 @@ //! - [`BicliqueCover`]: Biclique cover on bipartite graphs pub(crate) mod biclique_cover; +pub(crate) mod graph_partitioning; pub(crate) mod kcoloring; pub(crate) mod max_cut; pub(crate) mod maximal_is; @@ -26,6 +28,7 @@ pub(crate) mod spin_glass; pub(crate) mod traveling_salesman; pub use biclique_cover::BicliqueCover; +pub use graph_partitioning::GraphPartitioning; pub use kcoloring::KColoring; pub use max_cut::MaxCut; pub use maximal_is::MaximalIS; diff --git a/src/models/mod.rs b/src/models/mod.rs index 96b4b79d1..2a5866bb8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -12,8 +12,9 @@ pub mod set; pub use algebraic::{ClosestVectorProblem, BMF, ILP, QUBO}; pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ - BicliqueCover, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, - MaximumMatching, MinimumDominatingSet, MinimumVertexCover, SpinGlass, TravelingSalesman, + BicliqueCover, GraphPartitioning, KColoring, MaxCut, MaximalIS, MaximumClique, + MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumVertexCover, SpinGlass, + TravelingSalesman, }; pub use misc::{BinPacking, Factoring, Knapsack, PaintShop}; pub use set::{MaximumSetPacking, MinimumSetCovering}; diff --git a/src/unit_tests/models/graph/graph_partitioning.rs b/src/unit_tests/models/graph/graph_partitioning.rs new file mode 100644 index 000000000..f33e7506d --- /dev/null +++ b/src/unit_tests/models/graph/graph_partitioning.rs @@ -0,0 +1,169 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::Direction; + +/// Issue example: 6 vertices, edges forming two triangles connected by 3 edges. +/// Optimal partition A={0,1,2}, B={3,4,5}, cut=3. +fn issue_example() -> GraphPartitioning { + let graph = SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 4), + (3, 5), + (4, 5), + ], + ); + GraphPartitioning::new(graph) +} + +#[test] +fn test_graphpartitioning_basic() { + let problem = issue_example(); + + // Check dims: 6 binary variables + assert_eq!(problem.dims(), vec![2, 2, 2, 2, 2, 2]); + + // Evaluate a valid balanced partition: A={0,1,2}, B={3,4,5} + // config: [0, 0, 0, 1, 1, 1] + // Crossing edges: (1,3), (2,3), (2,4) => cut = 3 + let config = vec![0, 0, 0, 1, 1, 1]; + let result = problem.evaluate(&config); + assert_eq!(result, SolutionSize::Valid(3)); +} + +#[test] +fn test_graphpartitioning_direction() { + let problem = issue_example(); + assert_eq!(problem.direction(), Direction::Minimize); +} + +#[test] +fn test_graphpartitioning_serialization() { + let problem = issue_example(); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: GraphPartitioning = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.graph().num_vertices(), 6); + assert_eq!(deserialized.graph().num_edges(), 9); + + // Verify evaluation is consistent after round-trip + let config = vec![0, 0, 0, 1, 1, 1]; + assert_eq!(problem.evaluate(&config), deserialized.evaluate(&config)); +} + +#[test] +fn test_graphpartitioning_solver() { + let problem = issue_example(); + let solver = BruteForce::new(); + let best = solver.find_best(&problem).unwrap(); + let size = problem.evaluate(&best); + assert_eq!(size, SolutionSize::Valid(3)); + + // All optimal solutions should have cut = 3 + let all_best = solver.find_all_best(&problem); + assert!(!all_best.is_empty()); + for sol in &all_best { + assert_eq!(problem.evaluate(sol), SolutionSize::Valid(3)); + } +} + +#[test] +fn test_graphpartitioning_odd_vertices() { + // 3 vertices: all configs must be Invalid since n is odd + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = GraphPartitioning::new(graph); + + // Every possible config should be Invalid + for a in 0..2 { + for b in 0..2 { + for c in 0..2 { + assert_eq!( + problem.evaluate(&[a, b, c]), + SolutionSize::Invalid, + "Expected Invalid for odd n, config [{}, {}, {}]", + a, + b, + c + ); + } + } + } +} + +#[test] +fn test_graphpartitioning_unbalanced_invalid() { + // 4 vertices: only configs with exactly 2 ones are valid + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 3)]); + let problem = GraphPartitioning::new(graph); + + // All zeros: 0 ones, not balanced + assert_eq!(problem.evaluate(&[0, 0, 0, 0]), SolutionSize::Invalid); + + // All ones: 4 ones, not balanced + assert_eq!(problem.evaluate(&[1, 1, 1, 1]), SolutionSize::Invalid); + + // One vertex in partition 1: not balanced + assert_eq!(problem.evaluate(&[1, 0, 0, 0]), SolutionSize::Invalid); + + // Three vertices in partition 1: not balanced + assert_eq!(problem.evaluate(&[1, 1, 1, 0]), SolutionSize::Invalid); + + // Two vertices in partition 1: balanced, should be Valid + let result = problem.evaluate(&[1, 1, 0, 0]); + assert!(result.is_valid()); +} + +#[test] +fn test_graphpartitioning_size_getters() { + let problem = issue_example(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 9); +} + +#[test] +fn test_graphpartitioning_square_graph() { + // Square graph: 0-1, 1-2, 2-3, 3-0 (the doctest example) + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (3, 0)]); + let problem = GraphPartitioning::new(graph); + + let solver = BruteForce::new(); + let all_best = solver.find_all_best(&problem); + + // Minimum bisection of a 4-cycle: cut = 2 + for sol in &all_best { + assert_eq!(problem.evaluate(sol), SolutionSize::Valid(2)); + } +} + +#[test] +fn test_graphpartitioning_problem_name() { + assert_eq!( + as Problem>::NAME, + "GraphPartitioning" + ); +} + +#[test] +fn test_graphpartitioning_graph_accessor() { + let problem = issue_example(); + let graph = problem.graph(); + assert_eq!(graph.num_vertices(), 6); + assert_eq!(graph.num_edges(), 9); +} + +#[test] +fn test_graphpartitioning_empty_graph() { + // 4 vertices, no edges: any balanced partition has cut = 0 + let graph = SimpleGraph::new(4, vec![]); + let problem = GraphPartitioning::new(graph); + + let config = vec![0, 0, 1, 1]; + assert_eq!(problem.evaluate(&config), SolutionSize::Valid(0)); +} From a43de93285ed74d9e6db43f847dbe3bcf1fb2f2b Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 12 Mar 2026 14:34:49 +0800 Subject: [PATCH 3/7] Review fixes: strengthen test assertion, warn on odd vertex count - Assert exact cut value in unbalanced_invalid test - Warn and round up when random generation gets odd vertex count Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/commands/create.rs | 12 +++++++++++- src/unit_tests/models/graph/graph_partitioning.rs | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 0fee94fa9..fed2e172e 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -889,8 +889,18 @@ fn create_random( } } - // GraphPartitioning (graph only, no weights) + // GraphPartitioning (graph only, no weights; requires even vertex count) "GraphPartitioning" => { + let num_vertices = if num_vertices % 2 != 0 { + eprintln!( + "Warning: GraphPartitioning requires even vertex count; rounding {} up to {}", + num_vertices, + num_vertices + 1 + ); + num_vertices + 1 + } else { + num_vertices + }; let edge_prob = args.edge_prob.unwrap_or(0.5); if !(0.0..=1.0).contains(&edge_prob) { bail!("--edge-prob must be between 0.0 and 1.0"); diff --git a/src/unit_tests/models/graph/graph_partitioning.rs b/src/unit_tests/models/graph/graph_partitioning.rs index f33e7506d..e674432b6 100644 --- a/src/unit_tests/models/graph/graph_partitioning.rs +++ b/src/unit_tests/models/graph/graph_partitioning.rs @@ -116,8 +116,8 @@ fn test_graphpartitioning_unbalanced_invalid() { assert_eq!(problem.evaluate(&[1, 1, 1, 0]), SolutionSize::Invalid); // Two vertices in partition 1: balanced, should be Valid - let result = problem.evaluate(&[1, 1, 0, 0]); - assert!(result.is_valid()); + // 4-cycle edges: (0,1),(1,2),(2,3),(0,3). Config [1,1,0,0] cuts (1,2) and (0,3) => cut=2 + assert_eq!(problem.evaluate(&[1, 1, 0, 0]), SolutionSize::Valid(2)); } #[test] From 5b69d90f8bbb89ffcaf693fa8c7dbbbb2c20d0a9 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 12 Mar 2026 14:34:55 +0800 Subject: [PATCH 4/7] chore: remove plan file after implementation Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-12-graph-partitioning.md | 98 --------------------- 1 file changed, 98 deletions(-) delete mode 100644 docs/plans/2026-03-12-graph-partitioning.md diff --git a/docs/plans/2026-03-12-graph-partitioning.md b/docs/plans/2026-03-12-graph-partitioning.md deleted file mode 100644 index cc69177f8..000000000 --- a/docs/plans/2026-03-12-graph-partitioning.md +++ /dev/null @@ -1,98 +0,0 @@ -# Plan: Add GraphPartitioning Model (#117) - -## Overview - -Add the GraphPartitioning (Minimum Bisection) problem to the codebase. Given an undirected graph G = (V, E) with |V| = n (even), partition V into two equal halves A, B with |A| = |B| = n/2, minimizing the number of crossing edges. - -## Design Decisions - -- **Name:** `GraphPartitioning` (no prefix, per issue) -- **Type parameters:** Single parameter `G: Graph` (no weight type — unweighted edge counting) -- **Metric:** `SolutionSize` — cut size is an integer count -- **Direction:** `Minimize` -- **Feasibility:** Config is valid iff exactly n/2 vertices are assigned to each side (balanced bisection). If n is odd, all configs are `Invalid`. -- **Complexity:** `"2^num_vertices"` — brute-force bound (no known sub-exponential exact algorithm for general minimum bisection) -- **Solver:** Existing BruteForce works via OptimizationProblem trait -- **Category:** `graph/` (graph input) - -## Tasks - -### Task 1: Implement the model [independent] - -**File:** `src/models/graph/graph_partitioning.rs` - -Follow MaxCut as template, with these differences: -- Single type param `G` (no `W`) -- No edge weights — just count crossing edges -- `evaluate()` checks balanced bisection (sum of config == n/2), returns `Invalid` if not balanced or n is odd -- `direction()` returns `Minimize` -- `variant_params![G]` (single param) - -Structure: -1. `inventory::submit!` with `ProblemSchemaEntry` — fields: `[graph: G]` -2. Struct `GraphPartitioning` with field `graph: G` -3. `new(graph)` constructor + `graph()` accessor -4. Size getters: `num_vertices()`, `num_edges()` -5. `Problem` impl with `Metric = SolutionSize`, `dims() = vec![2; n]` -6. `OptimizationProblem` impl with `Value = i32`, `direction() = Minimize` -7. `declare_variants! { GraphPartitioning => "2^num_vertices" }` -8. `#[cfg(test)] #[path = "..."] mod tests;` - -### Task 2: Register the model [depends on Task 1] - -**Files to update:** -1. `src/models/graph/mod.rs` — add `pub(crate) mod graph_partitioning;` and `pub use graph_partitioning::GraphPartitioning;` -2. `src/models/mod.rs` — add `GraphPartitioning` to the graph re-export line - -### Task 3: Register in CLI [depends on Task 1] - -**Files to update:** - -1. `problemreductions-cli/src/dispatch.rs`: - - `load_problem()`: add `"GraphPartitioning" => deser_opt::>(data)` - - `serialize_any_problem()`: add `"GraphPartitioning" => try_ser::>(any)` - - Add import: `use problemreductions::models::graph::GraphPartitioning;` - -2. `problemreductions-cli/src/problem_name.rs`: - - `resolve_alias()`: add `"graphpartitioning" => "GraphPartitioning".to_string()` - -3. `problemreductions-cli/src/commands/create.rs`: - - `example_for()`: add `"GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3"` - - Main `create()` match: add a new arm for `"GraphPartitioning"` that parses `--graph` and constructs with `GraphPartitioning::new(graph)` - - Random generation arm: add `"GraphPartitioning"` with random graph + `variant_map(&[("graph", "SimpleGraph")])` - - Add import for `GraphPartitioning` - -### Task 4: Write unit tests [depends on Task 1] - -**File:** `src/unit_tests/models/graph/graph_partitioning.rs` - -Tests to write: -- `test_graphpartitioning_basic` — construct instance, verify dims, evaluate valid/invalid configs -- `test_graphpartitioning_direction` — verify `Minimize` -- `test_graphpartitioning_serialization` — round-trip serde -- `test_graphpartitioning_solver` — brute-force finds optimal partition matching issue example -- `test_graphpartitioning_odd_vertices` — all configs Invalid when n is odd -- `test_graphpartitioning_unbalanced_invalid` — non-balanced partitions return Invalid - -Use issue example: 6 vertices, edges (0,1),(0,2),(1,2),(1,3),(2,3),(2,4),(3,4),(3,5),(4,5), optimal cut = 3. - -Also register in `src/unit_tests/models/graph/mod.rs`. - -### Task 5: Document in paper [depends on Task 1] - -**File:** `docs/paper/reductions.typ` - -1. Add to `display-name` dict: `"GraphPartitioning": [Graph Partitioning]` -2. Add `#problem-def("GraphPartitioning")[...]` with: - - Formal definition of minimum bisection - - Background on VLSI, parallel computing applications - - Example with 6-vertex graph and CeTZ visualization - - Algorithm list (brute-force) - -### Task 6: Verify [depends on Tasks 1-5] - -```bash -make fmt -make clippy -make test -``` From ad82aa475edd49c20a2a027afab81a15ff898752 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 12 Mar 2026 17:15:05 +0800 Subject: [PATCH 5/7] fix: add config length check and binary validation in GraphPartitioning::evaluate Address Copilot review comments: validate config length matches vertex count and use filter-based counting instead of sum for correct binary detection. Co-Authored-By: Claude Opus 4.6 --- src/models/graph/graph_partitioning.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/models/graph/graph_partitioning.rs b/src/models/graph/graph_partitioning.rs index 3a00c783e..02d6beeb1 100644 --- a/src/models/graph/graph_partitioning.rs +++ b/src/models/graph/graph_partitioning.rs @@ -99,12 +99,15 @@ where fn evaluate(&self, config: &[usize]) -> SolutionSize { let n = self.graph.num_vertices(); + if config.len() != n { + return SolutionSize::Invalid; + } // Balanced bisection requires even n - if !n.is_multiple_of(2) { + if n % 2 != 0 { return SolutionSize::Invalid; } // Check balanced: exactly n/2 vertices in partition 1 - let count_ones: usize = config.iter().sum(); + let count_ones = config.iter().filter(|&&x| x == 1).count(); if count_ones != n / 2 { return SolutionSize::Invalid; } From 2e47afe1224d2d6f5676ff6b27121da6136e25ac Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 12 Mar 2026 18:52:13 +0800 Subject: [PATCH 6/7] fix: add GraphPartitioning to CLI help "Flags by problem type" table Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/cli.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 91e9bd252..44dfa163a 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -208,6 +208,7 @@ Flags by problem type: QUBO --matrix SpinGlass --graph, --couplings, --fields KColoring --graph, --k + GraphPartitioning --graph Factoring --target, --m, --n BinPacking --sizes, --capacity PaintShop --sequence From 9f777fae383c0a106a2979289ea6099f4cc4c6ca Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 12 Mar 2026 18:57:27 +0800 Subject: [PATCH 7/7] fix: use is_multiple_of() to satisfy clippy lint Co-Authored-By: Claude Opus 4.6 --- src/models/graph/graph_partitioning.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/graph/graph_partitioning.rs b/src/models/graph/graph_partitioning.rs index 02d6beeb1..b109a3479 100644 --- a/src/models/graph/graph_partitioning.rs +++ b/src/models/graph/graph_partitioning.rs @@ -103,7 +103,7 @@ where return SolutionSize::Invalid; } // Balanced bisection requires even n - if n % 2 != 0 { + if !n.is_multiple_of(2) { return SolutionSize::Invalid; } // Check balanced: exactly n/2 vertices in partition 1