From 5f4ebfafa34274fbc38236153aaa4cf8ed3da997 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 14:04:00 +0800 Subject: [PATCH 1/8] Add plan for #120: [Rule] GraphPartitioning to MaxCut --- .../2026-03-20-graphpartitioning-to-maxcut.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/plans/2026-03-20-graphpartitioning-to-maxcut.md diff --git a/docs/plans/2026-03-20-graphpartitioning-to-maxcut.md b/docs/plans/2026-03-20-graphpartitioning-to-maxcut.md new file mode 100644 index 000000000..f12686c03 --- /dev/null +++ b/docs/plans/2026-03-20-graphpartitioning-to-maxcut.md @@ -0,0 +1,177 @@ +# GraphPartitioning to MaxCut Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task in this session. + +**Goal:** Add the `GraphPartitioning -> MaxCut` reduction from issue #120, including closed-loop tests, a canonical rule example, exported fixtures, and a paper entry. + +**Architecture:** Reuse the same vertex set, build a weighted complete graph on every unordered vertex pair, and set edge weights to `P - 1` for original edges and `P` for non-edges with `P = num_edges + 1`. This makes every optimal MaxCut solution balanced first, then equivalent to a minimum-bisection solution on the source graph; solution extraction is the identity mapping on the partition bit-vector. + +**Tech Stack:** Rust crate code under `src/rules/` and `src/unit_tests/`, example-db exports, Typst paper docs, cargo/make verification commands. + +--- + +## Batch 1: Rule implementation, tests, and exports + +### Task 1: Add failing rule tests first + +**Files:** +- Create: `src/unit_tests/rules/graphpartitioning_maxcut.rs` +- Modify: `src/rules/mod.rs` +- Reference: `src/rules/minimumvertexcover_maximumindependentset.rs` +- Reference: `src/unit_tests/rules/minimumvertexcover_maximumindependentset.rs` +- Reference: `src/models/graph/graph_partitioning.rs` +- Reference: `src/models/graph/max_cut.rs` + +**Step 1: Write the failing test** + +Add tests that exercise the exact issue construction on the 6-vertex example from the issue: +- `test_graphpartitioning_to_maxcut_closed_loop` +- `test_graphpartitioning_to_maxcut_target_structure` +- `test_graphpartitioning_to_maxcut_extract_solution_identity` + +The structure test should assert: +- target `num_vertices == source.num_vertices()` +- target `num_edges == num_vertices * (num_vertices - 1) / 2` +- weights are `9` on source edges and `10` on non-edges for the issue example (`m = 9`, `P = 10`) + +The closed-loop test should use `assert_optimization_round_trip_from_optimization_target`. + +**Step 2: Run test to verify it fails** + +Run: `cargo test graphpartitioning_to_maxcut --lib` + +Expected: compile or link failure because the new rule module and `ReduceTo> for GraphPartitioning` do not exist yet. + +**Step 3: Commit** + +```bash +git add src/unit_tests/rules/graphpartitioning_maxcut.rs src/rules/mod.rs +git commit -m "test: add GraphPartitioning to MaxCut reduction tests" +``` + +### Task 2: Implement the reduction and register it + +**Files:** +- Create: `src/rules/graphpartitioning_maxcut.rs` +- Modify: `src/rules/mod.rs` +- Test: `src/unit_tests/rules/graphpartitioning_maxcut.rs` + +**Step 1: Write minimal implementation** + +Implement: +- `ReductionGPToMaxCut` storing the target `MaxCut` +- `ReductionResult` with identity `extract_solution` +- `#[reduction(overhead = { num_vertices = "num_vertices", num_edges = "num_vertices * (num_vertices - 1) / 2" })]` +- `ReduceTo> for GraphPartitioning` + +Construction details: +- `P = self.num_edges() as i32 + 1` +- Enumerate all pairs `(u, v)` with `u < v` +- Weight is `P - 1` if `(u, v)` is an original source edge, else `P` +- Build `MaxCut::new(SimpleGraph::new(n, complete_edges), weights)` + +Register the module in `src/rules/mod.rs` and extend `canonical_rule_example_specs()`. + +**Step 2: Run tests to verify they pass** + +Run: `cargo test graphpartitioning_to_maxcut --lib` + +Expected: all new reduction tests pass. + +**Step 3: Refactor only if needed** + +Keep the rule code minimal. If edge-membership checks are awkward, extract a tiny local helper inside the rule file rather than changing model APIs. + +**Step 4: Commit** + +```bash +git add src/rules/graphpartitioning_maxcut.rs src/rules/mod.rs src/unit_tests/rules/graphpartitioning_maxcut.rs +git commit -m "feat: add GraphPartitioning to MaxCut reduction" +``` + +### Task 3: Add canonical rule example and export support + +**Files:** +- Modify: `src/rules/graphpartitioning_maxcut.rs` +- Modify: `src/rules/mod.rs` +- Reference: `src/example_db/specs.rs` + +**Step 1: Add canonical example spec** + +Inside the new rule file, add `canonical_rule_example_specs()` under `#[cfg(feature = "example-db")]` using the issue’s 6-vertex graph and a stored witness: +- source config: `[0, 0, 0, 1, 1, 1]` +- target config: `[0, 0, 0, 1, 1, 1]` + +Use `rule_example_with_witness::<_, MaxCut>`. + +**Step 2: Run export/fixture commands** + +Run in order: +- `cargo run --example export_graph` +- `cargo run --example export_schemas` +- `make regenerate-fixtures` + +Expected: updated reduction graph, schemas, and example fixtures include the new rule. + +**Step 3: Run focused verification** + +Run: +- `cargo test graphpartitioning_to_maxcut --features example-db --lib` + +Expected: example-backed rule code still passes after export wiring. + +**Step 4: Commit** + +```bash +git add src/rules/graphpartitioning_maxcut.rs src/rules/mod.rs docs/src/reduction_graph.json docs/src/problem_schemas.json src/example_db/fixtures/examples.json +git commit -m "test: add GraphPartitioning to MaxCut fixtures" +``` + +## Batch 2: Paper entry after exports exist + +### Task 4: Document the reduction in the paper + +**Files:** +- Modify: `docs/paper/reductions.typ` +- Reference: `docs/paper/reductions.typ` section `reduction-rule("MaxCut", "SpinGlass", ...)` +- Reference: issue #120 body and comments cached in `/tmp/issue-120-context.json` + +**Step 1: Write the paper entry** + +Add a `GraphPartitioning -> MaxCut` `reduction-rule` block with: +- statement that the weighted-complete-graph construction is folklore in combinatorial optimization +- citation to `@garey1976` for the surrounding hardness context +- `_Construction._` with explicit piecewise weight formula +- `_Correctness._` showing `P = m + 1` forces balance and then complements the source objective +- `_Solution extraction._` as identity on vertex assignments +- worked example sourced from `load-example("GraphPartitioning", "MaxCut")` + +The example should enumerate the 15 target edges by weight class and verify the canonical witness cut value `87`. + +**Step 2: Verify the paper build** + +Run: `make paper` + +Expected: Typst compiles successfully with the new reduction example. + +**Step 3: Run final repo verification** + +Run: +- `make test` +- `make clippy` + +Expected: both commands pass after the new rule and paper entry land. + +**Step 4: Commit** + +```bash +git add docs/paper/reductions.typ +git commit -m "docs: add GraphPartitioning to MaxCut reduction" +``` + +## Notes for Execution + +- Use TDD strictly: do not write production code for the rule until the new tests are present and have been run red first. +- Do not change either model API unless the tests demonstrate an unavoidable gap. +- The issue comments already resolved the ambiguity around `P`: use `P = num_edges + 1`. +- The cited paper in the issue does not describe the exact construction; present the construction as folklore and avoid overstating the citation. From 87b57f2421f353288d4a89da50a771e4a5f13652 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 14:09:10 +0800 Subject: [PATCH 2/8] test: add GraphPartitioning to MaxCut reduction tests --- src/rules/mod.rs | 4 ++ .../rules/graphpartitioning_maxcut.rs | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/unit_tests/rules/graphpartitioning_maxcut.rs diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 4740bd9bd..aac50dcdb 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -42,6 +42,10 @@ pub(crate) mod travelingsalesman_qubo; pub mod unitdiskmapping; +#[cfg(test)] +#[path = "../unit_tests/rules/graphpartitioning_maxcut.rs"] +mod graphpartitioning_maxcut_tests; + #[cfg(feature = "ilp-solver")] pub(crate) mod binpacking_ilp; #[cfg(feature = "ilp-solver")] diff --git a/src/unit_tests/rules/graphpartitioning_maxcut.rs b/src/unit_tests/rules/graphpartitioning_maxcut.rs new file mode 100644 index 000000000..e52ea558b --- /dev/null +++ b/src/unit_tests/rules/graphpartitioning_maxcut.rs @@ -0,0 +1,64 @@ +use crate::models::graph::{GraphPartitioning, MaxCut}; +use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; +use crate::rules::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +fn issue_example() -> GraphPartitioning { + GraphPartitioning::new(SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 4), + (3, 5), + (4, 5), + ], + )) +} + +#[test] +fn test_graphpartitioning_to_maxcut_closed_loop() { + let source = issue_example(); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "GraphPartitioning->MaxCut closed loop", + ); +} + +#[test] +fn test_graphpartitioning_to_maxcut_target_structure() { + let source = issue_example(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + let num_vertices = source.num_vertices(); + + assert_eq!(target.num_vertices(), num_vertices); + assert_eq!(target.num_edges(), num_vertices * (num_vertices - 1) / 2); + + for u in 0..num_vertices { + for v in (u + 1)..num_vertices { + let expected_weight = if source.graph().has_edge(u, v) { 9 } else { 10 }; + assert_eq!( + target.edge_weight(u, v), + Some(&expected_weight), + "unexpected weight on edge ({u}, {v})" + ); + } + } +} + +#[test] +fn test_graphpartitioning_to_maxcut_extract_solution_identity() { + let source = issue_example(); + let reduction = ReduceTo::>::reduce_to(&source); + let target_solution = vec![0, 0, 0, 1, 1, 1]; + + assert_eq!(reduction.extract_solution(&target_solution), target_solution); +} From 9d0cf11312bbfa9e2b62f16b97aa0da1eb56be04 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 14:10:21 +0800 Subject: [PATCH 3/8] feat: add GraphPartitioning to MaxCut reduction --- src/rules/graphpartitioning_maxcut.rs | 62 +++++++++++++++++++++++++++ src/rules/mod.rs | 5 +-- 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 src/rules/graphpartitioning_maxcut.rs diff --git a/src/rules/graphpartitioning_maxcut.rs b/src/rules/graphpartitioning_maxcut.rs new file mode 100644 index 000000000..5922e48d5 --- /dev/null +++ b/src/rules/graphpartitioning_maxcut.rs @@ -0,0 +1,62 @@ +//! Reduction from GraphPartitioning to MaxCut on a weighted complete graph. + +use crate::models::graph::{GraphPartitioning, MaxCut}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; + +/// Result of reducing GraphPartitioning to MaxCut. +#[derive(Debug, Clone)] +pub struct ReductionGPToMaxCut { + target: MaxCut, +} + +impl ReductionResult for ReductionGPToMaxCut { + type Source = GraphPartitioning; + type Target = MaxCut; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +fn complete_graph_edges_and_weights(graph: &SimpleGraph) -> (Vec<(usize, usize)>, Vec) { + let num_vertices = graph.num_vertices(); + let p = graph.num_edges() as i32 + 1; + let mut edges = Vec::new(); + let mut weights = Vec::new(); + + for u in 0..num_vertices { + for v in (u + 1)..num_vertices { + edges.push((u, v)); + weights.push(if graph.has_edge(u, v) { p - 1 } else { p }); + } + } + + (edges, weights) +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_edges = "num_vertices * (num_vertices - 1) / 2", + } +)] +impl ReduceTo> for GraphPartitioning { + type Result = ReductionGPToMaxCut; + + fn reduce_to(&self) -> Self::Result { + let (edges, weights) = complete_graph_edges_and_weights(self.graph()); + let target = MaxCut::new(SimpleGraph::new(self.num_vertices(), edges), weights); + + ReductionGPToMaxCut { target } + } +} + +#[cfg(test)] +#[path = "../unit_tests/rules/graphpartitioning_maxcut.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index aac50dcdb..9238bff5c 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -9,6 +9,7 @@ pub use registry::{ReductionEntry, ReductionOverhead}; pub(crate) mod circuit_spinglass; pub(crate) mod coloring_qubo; pub(crate) mod factoring_circuit; +pub(crate) mod graphpartitioning_maxcut; mod graph; mod kcoloring_casts; mod knapsack_qubo; @@ -42,10 +43,6 @@ pub(crate) mod travelingsalesman_qubo; pub mod unitdiskmapping; -#[cfg(test)] -#[path = "../unit_tests/rules/graphpartitioning_maxcut.rs"] -mod graphpartitioning_maxcut_tests; - #[cfg(feature = "ilp-solver")] pub(crate) mod binpacking_ilp; #[cfg(feature = "ilp-solver")] From dc76f14a50b0305caad0b47accc9df9b46f2a49e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 14:12:43 +0800 Subject: [PATCH 4/8] test: add GraphPartitioning to MaxCut fixtures --- src/rules/graphpartitioning_maxcut.rs | 32 +++++++++++++++++++++++++++ src/rules/mod.rs | 1 + 2 files changed, 33 insertions(+) diff --git a/src/rules/graphpartitioning_maxcut.rs b/src/rules/graphpartitioning_maxcut.rs index 5922e48d5..7055e623f 100644 --- a/src/rules/graphpartitioning_maxcut.rs +++ b/src/rules/graphpartitioning_maxcut.rs @@ -57,6 +57,38 @@ impl ReduceTo> for GraphPartitioning { } } +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "graphpartitioning_to_maxcut", + build: || { + let source = GraphPartitioning::new(SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 4), + (3, 5), + (4, 5), + ], + )); + crate::example_db::specs::rule_example_with_witness::<_, MaxCut>( + source, + SolutionPair { + source_config: vec![0, 0, 0, 1, 1, 1], + target_config: vec![0, 0, 0, 1, 1, 1], + }, + ) + }, + }] +} + #[cfg(test)] #[path = "../unit_tests/rules/graphpartitioning_maxcut.rs"] mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 9238bff5c..a6db8d4fa 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -88,6 +88,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Date: Fri, 20 Mar 2026 14:22:54 +0800 Subject: [PATCH 5/8] refactor: harden GraphPartitioning to MaxCut reduction --- src/rules/graphpartitioning_maxcut.rs | 48 ++++++++++++------- .../rules/graphpartitioning_maxcut.rs | 30 ++++++------ 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/rules/graphpartitioning_maxcut.rs b/src/rules/graphpartitioning_maxcut.rs index 7055e623f..86f8aee47 100644 --- a/src/rules/graphpartitioning_maxcut.rs +++ b/src/rules/graphpartitioning_maxcut.rs @@ -11,6 +11,8 @@ pub struct ReductionGPToMaxCut { target: MaxCut, } +const ISSUE_EXAMPLE_WITNESS: [usize; 6] = [0, 0, 0, 1, 1, 1]; + impl ReductionResult for ReductionGPToMaxCut { type Source = GraphPartitioning; type Target = MaxCut; @@ -24,9 +26,26 @@ impl ReductionResult for ReductionGPToMaxCut { } } +fn issue_example() -> GraphPartitioning { + GraphPartitioning::new(SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 2), + (1, 3), + (2, 3), + (2, 4), + (3, 4), + (3, 5), + (4, 5), + ], + )) +} + fn complete_graph_edges_and_weights(graph: &SimpleGraph) -> (Vec<(usize, usize)>, Vec) { let num_vertices = graph.num_vertices(); - let p = graph.num_edges() as i32 + 1; + let p = penalty_weight(graph.num_edges()); let mut edges = Vec::new(); let mut weights = Vec::new(); @@ -40,6 +59,13 @@ fn complete_graph_edges_and_weights(graph: &SimpleGraph) -> (Vec<(usize, usize)> (edges, weights) } +fn penalty_weight(num_edges: usize) -> i32 { + i32::try_from(num_edges) + .ok() + .and_then(|num_edges| num_edges.checked_add(1)) + .expect("GraphPartitioning -> MaxCut penalty exceeds i32 range") +} + #[reduction( overhead = { num_vertices = "num_vertices", @@ -64,25 +90,11 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( - source, + issue_example(), SolutionPair { - source_config: vec![0, 0, 0, 1, 1, 1], - target_config: vec![0, 0, 0, 1, 1, 1], + source_config: ISSUE_EXAMPLE_WITNESS.to_vec(), + target_config: ISSUE_EXAMPLE_WITNESS.to_vec(), }, ) }, diff --git a/src/unit_tests/rules/graphpartitioning_maxcut.rs b/src/unit_tests/rules/graphpartitioning_maxcut.rs index e52ea558b..6028c88fb 100644 --- a/src/unit_tests/rules/graphpartitioning_maxcut.rs +++ b/src/unit_tests/rules/graphpartitioning_maxcut.rs @@ -4,20 +4,7 @@ use crate::rules::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; fn issue_example() -> GraphPartitioning { - GraphPartitioning::new(SimpleGraph::new( - 6, - vec![ - (0, 1), - (0, 2), - (1, 2), - (1, 3), - (2, 3), - (2, 4), - (3, 4), - (3, 5), - (4, 5), - ], - )) + super::issue_example() } #[test] @@ -38,13 +25,18 @@ fn test_graphpartitioning_to_maxcut_target_structure() { let reduction = ReduceTo::>::reduce_to(&source); let target = reduction.target_problem(); let num_vertices = source.num_vertices(); + let penalty = i32::try_from(source.num_edges()).unwrap() + 1; assert_eq!(target.num_vertices(), num_vertices); assert_eq!(target.num_edges(), num_vertices * (num_vertices - 1) / 2); for u in 0..num_vertices { for v in (u + 1)..num_vertices { - let expected_weight = if source.graph().has_edge(u, v) { 9 } else { 10 }; + let expected_weight = if source.graph().has_edge(u, v) { + penalty - 1 + } else { + penalty + }; assert_eq!( target.edge_weight(u, v), Some(&expected_weight), @@ -58,7 +50,13 @@ fn test_graphpartitioning_to_maxcut_target_structure() { fn test_graphpartitioning_to_maxcut_extract_solution_identity() { let source = issue_example(); let reduction = ReduceTo::>::reduce_to(&source); - let target_solution = vec![0, 0, 0, 1, 1, 1]; + let target_solution = super::ISSUE_EXAMPLE_WITNESS.to_vec(); assert_eq!(reduction.extract_solution(&target_solution), target_solution); } + +#[test] +fn test_graphpartitioning_to_maxcut_penalty_overflow_panics() { + let result = std::panic::catch_unwind(|| super::penalty_weight(i32::MAX as usize)); + assert!(result.is_err()); +} From 4fd3c95cce6d40b7e4c5029f6e16ab1515db0407 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 14:27:46 +0800 Subject: [PATCH 6/8] docs: add GraphPartitioning to MaxCut reduction --- docs/paper/reductions.typ | 46 +++++++++++++++++++++++++++ src/rules/graphpartitioning_maxcut.rs | 2 ++ 2 files changed, 48 insertions(+) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ac2135880..40d26e423 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -3964,6 +3964,52 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead _Solution extraction._ For VC solution $C$, return $S = V backslash C$, i.e.\ flip each variable: $s_v = 1 - c_v$. ] +#let gp_mc = load-example("GraphPartitioning", "MaxCut") +#let gp_mc_sol = gp_mc.solutions.at(0) +#let gp_mc_source_edges = gp_mc.source.instance.graph.edges.map(e => (e.at(0), e.at(1))) +#let gp_mc_target_edges = gp_mc.target.instance.graph.edges.map(e => (e.at(0), e.at(1))) +#let gp_mc_weights = gp_mc.target.instance.edge_weights +#let gp_mc_nv = gp_mc.source.instance.graph.num_vertices +#let gp_mc_ne = gp_mc_source_edges.len() +#let gp_mc_penalty = gp_mc_ne + 1 +#let gp_mc_side_a = range(gp_mc_nv).filter(i => gp_mc_sol.source_config.at(i) == 0) +#let gp_mc_side_b = range(gp_mc_nv).filter(i => gp_mc_sol.source_config.at(i) == 1) +#let gp_mc_weight_lo = gp_mc_target_edges.enumerate().filter(((i, e)) => gp_mc_weights.at(i) == gp_mc_penalty - 1).map(((i, e)) => e) +#let gp_mc_weight_hi = gp_mc_target_edges.enumerate().filter(((i, e)) => gp_mc_weights.at(i) == gp_mc_penalty).map(((i, e)) => e) +#let gp_mc_source_cross = gp_mc_source_edges.filter(e => gp_mc_sol.source_config.at(e.at(0)) != gp_mc_sol.source_config.at(e.at(1))) +#let gp_mc_cut_lo = gp_mc_target_edges.enumerate().filter(((i, e)) => + gp_mc_weights.at(i) == gp_mc_penalty - 1 and + gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1)) +).map(((i, e)) => e) +#let gp_mc_cut_hi = gp_mc_target_edges.enumerate().filter(((i, e)) => + gp_mc_weights.at(i) == gp_mc_penalty and + gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1)) +).map(((i, e)) => e) +#let gp_mc_cut_value = gp_mc_target_edges.enumerate().filter(((i, e)) => + gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1)) +).map(((i, e)) => gp_mc_weights.at(i)).sum(default: 0) +#reduction-rule("GraphPartitioning", "MaxCut", + example: true, + example-caption: [6-vertex minimum bisection to weighted Max-Cut], + extra: [ + Here $m = #gp_mc_ne$, so $P = m + 1 = #gp_mc_penalty$ \ + Weight $#(gp_mc_penalty - 1)$ edges (original edges): {#gp_mc_weight_lo.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")} \ + Weight $#gp_mc_penalty$ edges (non-edges): {#gp_mc_weight_hi.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")} \ + Canonical witness $A = {#gp_mc_side_a.map(i => $v_#i$).join(", ")}$, $B = {#gp_mc_side_b.map(i => $v_#i$).join(", ")}$ cuts source edges {#gp_mc_source_cross.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")} and attains weighted cut $#gp_mc_cut_lo.len() * #(gp_mc_penalty - 1) + #gp_mc_cut_hi.len() * #gp_mc_penalty = #gp_mc_cut_value$ #sym.checkmark + ], +)[ + @garey1976 Graph Partitioning minimizes cut edges subject to a perfect-balance constraint, while Max-Cut maximizes a weighted cut without any balance constraint. A standard folklore construction in combinatorial optimization removes that constraint by rewarding every cross-pair equally and then subtracting one unit on original edges. The resulting weighted complete graph forces every optimum to be balanced first, and among balanced cuts it exactly minimizes the original bisection width. +][ + _Construction._ Given a Graph Partitioning instance $G = (V, E)$ with $n = |V|$ and $m = |E|$, set $P = m + 1$. Build the complete graph $G' = (V, E')$ on the same vertex set, where $E'$ contains every unordered pair $\{u, v\}$ with $u != v$. Assign weight $w'_(u, v) = P - 1$ when $(u, v) in E$, and $w'_(u, v) = P$ otherwise. For any partition $(A, B)$ of $V$, the weighted cut in $G'$ is + $ "cut"_(G')(A, B) = P |A| |B| - "cut"_G(A, B). $ + + _Correctness._ ($arrow.r.double$) Let $(A, B)$ be a maximum cut of $G'$. If it were unbalanced, then $|A| |B|$ would be at least one smaller than for a balanced partition $(A', B')$. Hence + $ "cut"_(G')(A', B') - "cut"_(G')(A, B) >= P - ("cut"_G(A', B') - "cut"_G(A, B)) >= P - m > 0, $ + because $0 <= "cut"_G(·, ·) <= m$ and $P = m + 1$. Therefore every maximum cut of $G'$ is balanced. Among balanced partitions, $P |A| |B| = P (n slash 2)^2$ is constant, so maximizing $"cut"_(G')(A, B)$ is equivalent to minimizing $"cut"_G(A, B)$. ($arrow.l.double$) Conversely, every minimum bisection of $G$ is balanced and therefore maximizes $P |A| |B| - "cut"_G(A, B)$ in $G'$. + + _Solution extraction._ Read off the same partition vector on the original vertex set: the Max-Cut bit for vertex $v$ is already the Graph Partitioning bit for $v$. +] + #let mis_clique = load-example("MaximumIndependentSet", "MaximumClique") #let mis_clique_sol = mis_clique.solutions.at(0) #reduction-rule("MaximumIndependentSet", "MaximumClique", diff --git a/src/rules/graphpartitioning_maxcut.rs b/src/rules/graphpartitioning_maxcut.rs index 86f8aee47..0bf31b369 100644 --- a/src/rules/graphpartitioning_maxcut.rs +++ b/src/rules/graphpartitioning_maxcut.rs @@ -11,6 +11,7 @@ pub struct ReductionGPToMaxCut { target: MaxCut, } +#[cfg(any(test, feature = "example-db"))] const ISSUE_EXAMPLE_WITNESS: [usize; 6] = [0, 0, 0, 1, 1, 1]; impl ReductionResult for ReductionGPToMaxCut { @@ -26,6 +27,7 @@ impl ReductionResult for ReductionGPToMaxCut { } } +#[cfg(any(test, feature = "example-db"))] fn issue_example() -> GraphPartitioning { GraphPartitioning::new(SimpleGraph::new( 6, From 6a58dbea6bd49d77232b799c9d111d0214bbb957 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 14:28:00 +0800 Subject: [PATCH 7/8] chore: remove plan file after implementation --- .../2026-03-20-graphpartitioning-to-maxcut.md | 177 ------------------ 1 file changed, 177 deletions(-) delete mode 100644 docs/plans/2026-03-20-graphpartitioning-to-maxcut.md diff --git a/docs/plans/2026-03-20-graphpartitioning-to-maxcut.md b/docs/plans/2026-03-20-graphpartitioning-to-maxcut.md deleted file mode 100644 index f12686c03..000000000 --- a/docs/plans/2026-03-20-graphpartitioning-to-maxcut.md +++ /dev/null @@ -1,177 +0,0 @@ -# GraphPartitioning to MaxCut Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task in this session. - -**Goal:** Add the `GraphPartitioning -> MaxCut` reduction from issue #120, including closed-loop tests, a canonical rule example, exported fixtures, and a paper entry. - -**Architecture:** Reuse the same vertex set, build a weighted complete graph on every unordered vertex pair, and set edge weights to `P - 1` for original edges and `P` for non-edges with `P = num_edges + 1`. This makes every optimal MaxCut solution balanced first, then equivalent to a minimum-bisection solution on the source graph; solution extraction is the identity mapping on the partition bit-vector. - -**Tech Stack:** Rust crate code under `src/rules/` and `src/unit_tests/`, example-db exports, Typst paper docs, cargo/make verification commands. - ---- - -## Batch 1: Rule implementation, tests, and exports - -### Task 1: Add failing rule tests first - -**Files:** -- Create: `src/unit_tests/rules/graphpartitioning_maxcut.rs` -- Modify: `src/rules/mod.rs` -- Reference: `src/rules/minimumvertexcover_maximumindependentset.rs` -- Reference: `src/unit_tests/rules/minimumvertexcover_maximumindependentset.rs` -- Reference: `src/models/graph/graph_partitioning.rs` -- Reference: `src/models/graph/max_cut.rs` - -**Step 1: Write the failing test** - -Add tests that exercise the exact issue construction on the 6-vertex example from the issue: -- `test_graphpartitioning_to_maxcut_closed_loop` -- `test_graphpartitioning_to_maxcut_target_structure` -- `test_graphpartitioning_to_maxcut_extract_solution_identity` - -The structure test should assert: -- target `num_vertices == source.num_vertices()` -- target `num_edges == num_vertices * (num_vertices - 1) / 2` -- weights are `9` on source edges and `10` on non-edges for the issue example (`m = 9`, `P = 10`) - -The closed-loop test should use `assert_optimization_round_trip_from_optimization_target`. - -**Step 2: Run test to verify it fails** - -Run: `cargo test graphpartitioning_to_maxcut --lib` - -Expected: compile or link failure because the new rule module and `ReduceTo> for GraphPartitioning` do not exist yet. - -**Step 3: Commit** - -```bash -git add src/unit_tests/rules/graphpartitioning_maxcut.rs src/rules/mod.rs -git commit -m "test: add GraphPartitioning to MaxCut reduction tests" -``` - -### Task 2: Implement the reduction and register it - -**Files:** -- Create: `src/rules/graphpartitioning_maxcut.rs` -- Modify: `src/rules/mod.rs` -- Test: `src/unit_tests/rules/graphpartitioning_maxcut.rs` - -**Step 1: Write minimal implementation** - -Implement: -- `ReductionGPToMaxCut` storing the target `MaxCut` -- `ReductionResult` with identity `extract_solution` -- `#[reduction(overhead = { num_vertices = "num_vertices", num_edges = "num_vertices * (num_vertices - 1) / 2" })]` -- `ReduceTo> for GraphPartitioning` - -Construction details: -- `P = self.num_edges() as i32 + 1` -- Enumerate all pairs `(u, v)` with `u < v` -- Weight is `P - 1` if `(u, v)` is an original source edge, else `P` -- Build `MaxCut::new(SimpleGraph::new(n, complete_edges), weights)` - -Register the module in `src/rules/mod.rs` and extend `canonical_rule_example_specs()`. - -**Step 2: Run tests to verify they pass** - -Run: `cargo test graphpartitioning_to_maxcut --lib` - -Expected: all new reduction tests pass. - -**Step 3: Refactor only if needed** - -Keep the rule code minimal. If edge-membership checks are awkward, extract a tiny local helper inside the rule file rather than changing model APIs. - -**Step 4: Commit** - -```bash -git add src/rules/graphpartitioning_maxcut.rs src/rules/mod.rs src/unit_tests/rules/graphpartitioning_maxcut.rs -git commit -m "feat: add GraphPartitioning to MaxCut reduction" -``` - -### Task 3: Add canonical rule example and export support - -**Files:** -- Modify: `src/rules/graphpartitioning_maxcut.rs` -- Modify: `src/rules/mod.rs` -- Reference: `src/example_db/specs.rs` - -**Step 1: Add canonical example spec** - -Inside the new rule file, add `canonical_rule_example_specs()` under `#[cfg(feature = "example-db")]` using the issue’s 6-vertex graph and a stored witness: -- source config: `[0, 0, 0, 1, 1, 1]` -- target config: `[0, 0, 0, 1, 1, 1]` - -Use `rule_example_with_witness::<_, MaxCut>`. - -**Step 2: Run export/fixture commands** - -Run in order: -- `cargo run --example export_graph` -- `cargo run --example export_schemas` -- `make regenerate-fixtures` - -Expected: updated reduction graph, schemas, and example fixtures include the new rule. - -**Step 3: Run focused verification** - -Run: -- `cargo test graphpartitioning_to_maxcut --features example-db --lib` - -Expected: example-backed rule code still passes after export wiring. - -**Step 4: Commit** - -```bash -git add src/rules/graphpartitioning_maxcut.rs src/rules/mod.rs docs/src/reduction_graph.json docs/src/problem_schemas.json src/example_db/fixtures/examples.json -git commit -m "test: add GraphPartitioning to MaxCut fixtures" -``` - -## Batch 2: Paper entry after exports exist - -### Task 4: Document the reduction in the paper - -**Files:** -- Modify: `docs/paper/reductions.typ` -- Reference: `docs/paper/reductions.typ` section `reduction-rule("MaxCut", "SpinGlass", ...)` -- Reference: issue #120 body and comments cached in `/tmp/issue-120-context.json` - -**Step 1: Write the paper entry** - -Add a `GraphPartitioning -> MaxCut` `reduction-rule` block with: -- statement that the weighted-complete-graph construction is folklore in combinatorial optimization -- citation to `@garey1976` for the surrounding hardness context -- `_Construction._` with explicit piecewise weight formula -- `_Correctness._` showing `P = m + 1` forces balance and then complements the source objective -- `_Solution extraction._` as identity on vertex assignments -- worked example sourced from `load-example("GraphPartitioning", "MaxCut")` - -The example should enumerate the 15 target edges by weight class and verify the canonical witness cut value `87`. - -**Step 2: Verify the paper build** - -Run: `make paper` - -Expected: Typst compiles successfully with the new reduction example. - -**Step 3: Run final repo verification** - -Run: -- `make test` -- `make clippy` - -Expected: both commands pass after the new rule and paper entry land. - -**Step 4: Commit** - -```bash -git add docs/paper/reductions.typ -git commit -m "docs: add GraphPartitioning to MaxCut reduction" -``` - -## Notes for Execution - -- Use TDD strictly: do not write production code for the rule until the new tests are present and have been run red first. -- Do not change either model API unless the tests demonstrate an unavoidable gap. -- The issue comments already resolved the ambiguity around `P`: use `P = num_edges + 1`. -- The cited paper in the issue does not describe the exact construction; present the construction as folklore and avoid overstating the citation. From 1e4157ddc32bba3c69620ef64e0b3a93f354fc3b Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 21 Mar 2026 01:51:48 +0800 Subject: [PATCH 8/8] Fix formatting and clippy issues after merge with main - Fix mod.rs ordering for graphpartitioning_maxcut - Fix rustfmt issues in test file and qbf.rs (from merge) - Fix clippy needless_range_loop in closestvectorproblem_qubo.rs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/formula/qbf.rs | 1 - src/rules/closestvectorproblem_qubo.rs | 3 +-- src/rules/mod.rs | 2 +- src/unit_tests/models/formula/qbf.rs | 7 ++----- src/unit_tests/rules/graphpartitioning_maxcut.rs | 5 ++++- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/models/formula/qbf.rs b/src/models/formula/qbf.rs index 5b48522c3..830ddc167 100644 --- a/src/models/formula/qbf.rs +++ b/src/models/formula/qbf.rs @@ -153,7 +153,6 @@ impl QuantifiedBooleanFormulas { } } } - } impl Problem for QuantifiedBooleanFormulas { diff --git a/src/rules/closestvectorproblem_qubo.rs b/src/rules/closestvectorproblem_qubo.rs index 69a2941b5..bfc4b6c73 100644 --- a/src/rules/closestvectorproblem_qubo.rs +++ b/src/rules/closestvectorproblem_qubo.rs @@ -163,8 +163,7 @@ impl ReduceTo> for ClosestVectorProblem { matrix[u][u] = gram[var_u][var_u] * weight_u * weight_u + 2.0 * weight_u * g_lo_minus_h[var_u]; - for v in (u + 1)..total_bits { - let (var_v, weight_v) = bit_terms[v]; + for (v, &(var_v, weight_v)) in bit_terms.iter().enumerate().skip(u + 1) { matrix[u][v] = 2.0 * gram[var_u][var_v] * weight_u * weight_v; } } diff --git a/src/rules/mod.rs b/src/rules/mod.rs index dc5c95936..04062a144 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -10,8 +10,8 @@ pub(crate) mod circuit_spinglass; mod closestvectorproblem_qubo; pub(crate) mod coloring_qubo; pub(crate) mod factoring_circuit; -pub(crate) mod graphpartitioning_maxcut; mod graph; +pub(crate) mod graphpartitioning_maxcut; mod kcoloring_casts; mod knapsack_qubo; mod ksatisfiability_casts; diff --git a/src/unit_tests/models/formula/qbf.rs b/src/unit_tests/models/formula/qbf.rs index be46c741f..eccd7d28b 100644 --- a/src/unit_tests/models/formula/qbf.rs +++ b/src/unit_tests/models/formula/qbf.rs @@ -255,10 +255,7 @@ fn test_qbf_quantifier_clone() { #[test] fn test_qbf_empty_clause() { // An empty clause (disjunction of zero literals) is always false - let problem = QuantifiedBooleanFormulas::new( - 1, - vec![Quantifier::Exists], - vec![CNFClause::new(vec![])], - ); + let problem = + QuantifiedBooleanFormulas::new(1, vec![Quantifier::Exists], vec![CNFClause::new(vec![])]); assert!(!problem.is_true()); } diff --git a/src/unit_tests/rules/graphpartitioning_maxcut.rs b/src/unit_tests/rules/graphpartitioning_maxcut.rs index 6028c88fb..ca9acf458 100644 --- a/src/unit_tests/rules/graphpartitioning_maxcut.rs +++ b/src/unit_tests/rules/graphpartitioning_maxcut.rs @@ -52,7 +52,10 @@ fn test_graphpartitioning_to_maxcut_extract_solution_identity() { let reduction = ReduceTo::>::reduce_to(&source); let target_solution = super::ISSUE_EXAMPLE_WITNESS.to_vec(); - assert_eq!(reduction.extract_solution(&target_solution), target_solution); + assert_eq!( + reduction.extract_solution(&target_solution), + target_solution + ); } #[test]