Skip to content

Commit d111c55

Browse files
GiggleLiuclaude
andauthored
Fix #119: [Rule] GraphPartitioning to QUBO (#706)
* Add plan for #119: [Rule] GraphPartitioning to QUBO * test: add failing GraphPartitioning to QUBO coverage * feat: add GraphPartitioning to QUBO reduction * docs: add GraphPartitioning to QUBO paper entry * chore: finalize GraphPartitioning to QUBO pipeline work * Add GraphPartitioning->QUBO to dominated-rules allow-list After merging main (which added GraphPartitioning->MaxCut), the indirect path GraphPartitioning->MaxCut->SpinGlass->QUBO dominates the direct GraphPartitioning->QUBO reduction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ca2110e commit d111c55

5 files changed

Lines changed: 230 additions & 0 deletions

File tree

docs/paper/reductions.typ

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4737,6 +4737,48 @@ where $P$ is a penalty weight large enough that any constraint violation costs m
47374737
_Solution extraction._ For each vertex $u$, find terminal position $t$ with $x_(u,t) = 1$. For each edge $(u,v)$, output 1 (cut) if $u$ and $v$ are in different components, 0 otherwise.
47384738
]
47394739

4740+
#let gp_qubo = load-example("GraphPartitioning", "QUBO")
4741+
#let gp_qubo_sol = gp_qubo.solutions.at(0)
4742+
#let gp_qubo_edges = gp_qubo.source.instance.graph.edges.map(e => (e.at(0), e.at(1)))
4743+
#let gp_qubo_n = gp_qubo.source.instance.graph.num_vertices
4744+
#let gp_qubo_m = gp_qubo_edges.len()
4745+
#let gp_qubo_penalty = gp_qubo_m + 1
4746+
#let gp_qubo_diag = range(0, gp_qubo_n).map(i => gp_qubo.target.instance.matrix.at(i).at(i))
4747+
#let gp_qubo_cut_edges = gp_qubo_edges.filter(e => gp_qubo_sol.source_config.at(e.at(0)) != gp_qubo_sol.source_config.at(e.at(1)))
4748+
#let gp_qubo_cut_size = gp_qubo_cut_edges.len()
4749+
#reduction-rule("GraphPartitioning", "QUBO",
4750+
example: true,
4751+
example-caption: [6-vertex balanced partition instance ($n = #gp_qubo_n$, $|E| = #gp_qubo_m$)],
4752+
extra: [
4753+
*Step 1 -- Binary partition variables.* Introduce one binary variable per vertex: $x_i = 0$ means vertex $i$ is in the left block, $x_i = 1$ means it is in the right block. For the canonical instance, this gives $n = #gp_qubo_n$ QUBO variables:
4754+
$ x_0, x_1, x_2, x_3, x_4, x_5 $
4755+
4756+
*Step 2 -- Choose the balance penalty.* The source graph has $m = #gp_qubo_m$ edges, so the construction uses $P = m + 1 = #gp_qubo_penalty$. Any imbalance contributes at least $P$, which is already larger than the maximum possible cut size of any balanced partition.
4757+
4758+
*Step 3 -- Fill the QUBO matrix.* The diagonal entries are $Q_(i i) = deg(i) + P(1 - n)$, which evaluates here to $(#gp_qubo_diag.map(str).join(", "))$. For every pair $i < j$, start from $Q_(i j) = 2P = #(2 * gp_qubo_penalty)$, then subtract $2$ when $(i,j)$ is an edge. Hence edge coefficients become $18$ while non-edge coefficients stay $20$; the exported upper-triangular matrix matches the issue example exactly.\
4759+
4760+
*Step 4 -- Verify a solution.* The exported witness is $bold(x) = (#gp_qubo_sol.target_config.map(str).join(", "))$, which is also the source partition encoding. The cut edges are #gp_qubo_cut_edges.map(e => "(" + str(e.at(0)) + "," + str(e.at(1)) + ")").join(", "), so the cut size is $#gp_qubo_cut_size$ and the balance penalty vanishes because exactly #(gp_qubo_n / 2) vertices are assigned to the right block #sym.checkmark.
4761+
],
4762+
)[
4763+
Graph Partitioning (minimum bisection) asks for a balanced bipartition minimizing the number of crossing edges. Lucas's Ising formulation @lucas2014 translates directly to a QUBO by combining a cut-counting quadratic objective with a quadratic equality penalty enforcing $sum_i x_i = n / 2$. The reduction uses one binary variable per source vertex, so the QUBO has exactly $n$ variables.
4764+
][
4765+
_Construction._ Given an undirected graph $G = (V, E)$ with even $n = |V|$ and $m = |E|$, introduce binary variables $x_i in {0,1}$ for each vertex $i in V$. Interpret $x_i = 0$ as $i in A$ and $x_i = 1$ as $i in B$. The cut objective is:
4766+
$ H_"cut" = sum_((u,v) in E) (x_u + x_v - 2 x_u x_v) $
4767+
because the term equals $1$ exactly when edge $(u,v)$ crosses the partition. To enforce balance, add:
4768+
$ H_"bal" = P (sum_i x_i - n/2)^2 $
4769+
with penalty $P = m + 1$. The QUBO objective is $H = H_"cut" + H_"bal"$.
4770+
4771+
Expanding $H_"bal"$ with $x_i^2 = x_i$ gives:
4772+
$ H_"bal" = P (1 - n) sum_i x_i + 2 P sum_(i < j) x_i x_j + P n^2 / 4 $
4773+
so the upper-triangular QUBO coefficients are:
4774+
$ Q_(i i) = deg(i) + P (1 - n) $
4775+
and for $i < j$, $Q_(i j) = 2 P$ for every pair, then subtract $2$ whenever $(i,j) in E$. Equivalently, edge pairs have coefficient $2P - 2$ and non-edge pairs have coefficient $2P$. The additive constant $P n^2 / 4$ does not affect the minimizer.
4776+
4777+
_Correctness._ ($arrow.r.double$) If $bold(x)$ encodes a balanced partition, then $sum_i x_i = n/2$ and $H_"bal" = 0$, so the QUBO objective equals the cut size exactly. ($arrow.l.double$) If $bold(x)$ is imbalanced, then $|sum_i x_i - n/2| >= 1$, hence $H_"bal" >= P = m + 1$. Every balanced partition has cut size at most $m$, so any imbalanced assignment has objective strictly larger than at least one balanced assignment. Therefore every QUBO minimizer is balanced, and among balanced assignments minimizing $H$ is identical to minimizing the cut size.
4778+
4779+
_Solution extraction._ Return the QUBO bit-vector directly: the same binary assignment already records the source partition.
4780+
]
4781+
47404782
#let qubo_ilp = load-example("QUBO", "ILP")
47414783
#let qubo_ilp_sol = qubo_ilp.solutions.at(0)
47424784
#reduction-rule("QUBO", "ILP",
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
//! Reduction from GraphPartitioning to QUBO.
2+
//!
3+
//! Uses the penalty-method QUBO
4+
//! H = sum_(u,v in E) (x_u + x_v - 2 x_u x_v) + P (sum_i x_i - n/2)^2
5+
//! with P = |E| + 1 so any imbalanced partition is dominated by a balanced one.
6+
7+
use crate::models::algebraic::QUBO;
8+
use crate::models::graph::GraphPartitioning;
9+
use crate::reduction;
10+
use crate::rules::traits::{ReduceTo, ReductionResult};
11+
use crate::topology::{Graph, SimpleGraph};
12+
13+
/// Result of reducing GraphPartitioning to QUBO.
14+
#[derive(Debug, Clone)]
15+
pub struct ReductionGraphPartitioningToQUBO {
16+
target: QUBO<f64>,
17+
}
18+
19+
impl ReductionResult for ReductionGraphPartitioningToQUBO {
20+
type Source = GraphPartitioning<SimpleGraph>;
21+
type Target = QUBO<f64>;
22+
23+
fn target_problem(&self) -> &Self::Target {
24+
&self.target
25+
}
26+
27+
fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
28+
target_solution.to_vec()
29+
}
30+
}
31+
32+
#[reduction(overhead = { num_vars = "num_vertices" })]
33+
impl ReduceTo<QUBO<f64>> for GraphPartitioning<SimpleGraph> {
34+
type Result = ReductionGraphPartitioningToQUBO;
35+
36+
fn reduce_to(&self) -> Self::Result {
37+
let n = self.num_vertices();
38+
let penalty = self.num_edges() as f64 + 1.0;
39+
let mut matrix = vec![vec![0.0f64; n]; n];
40+
let mut degrees = vec![0usize; n];
41+
let edges = self.graph().edges();
42+
43+
for &(u, v) in &edges {
44+
degrees[u] += 1;
45+
degrees[v] += 1;
46+
}
47+
48+
for (i, row) in matrix.iter_mut().enumerate() {
49+
row[i] = degrees[i] as f64 + penalty * (1.0 - n as f64);
50+
for value in row.iter_mut().skip(i + 1) {
51+
*value = 2.0 * penalty;
52+
}
53+
}
54+
55+
for (u, v) in edges {
56+
let (lo, hi) = if u < v { (u, v) } else { (v, u) };
57+
matrix[lo][hi] -= 2.0;
58+
}
59+
60+
ReductionGraphPartitioningToQUBO {
61+
target: QUBO::from_matrix(matrix),
62+
}
63+
}
64+
}
65+
66+
#[cfg(feature = "example-db")]
67+
pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
68+
use crate::export::SolutionPair;
69+
70+
vec![crate::example_db::specs::RuleExampleSpec {
71+
id: "graphpartitioning_to_qubo",
72+
build: || {
73+
crate::example_db::specs::rule_example_with_witness::<_, QUBO<f64>>(
74+
GraphPartitioning::new(SimpleGraph::new(
75+
6,
76+
vec![
77+
(0, 1),
78+
(0, 2),
79+
(1, 2),
80+
(1, 3),
81+
(2, 3),
82+
(2, 4),
83+
(3, 4),
84+
(3, 5),
85+
(4, 5),
86+
],
87+
)),
88+
SolutionPair {
89+
source_config: vec![0, 0, 0, 1, 1, 1],
90+
target_config: vec![0, 0, 0, 1, 1, 1],
91+
},
92+
)
93+
},
94+
}]
95+
}
96+
97+
#[cfg(test)]
98+
#[path = "../unit_tests/rules/graphpartitioning_qubo.rs"]
99+
mod tests;

src/rules/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub(crate) mod coloring_qubo;
1212
pub(crate) mod factoring_circuit;
1313
mod graph;
1414
pub(crate) mod graphpartitioning_maxcut;
15+
pub(crate) mod graphpartitioning_qubo;
1516
mod kcoloring_casts;
1617
mod knapsack_qubo;
1718
mod ksatisfiability_casts;
@@ -100,6 +101,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::Ru
100101
specs.extend(coloring_qubo::canonical_rule_example_specs());
101102
specs.extend(factoring_circuit::canonical_rule_example_specs());
102103
specs.extend(graphpartitioning_maxcut::canonical_rule_example_specs());
104+
specs.extend(graphpartitioning_qubo::canonical_rule_example_specs());
103105
specs.extend(knapsack_qubo::canonical_rule_example_specs());
104106
specs.extend(ksatisfiability_qubo::canonical_rule_example_specs());
105107
specs.extend(ksatisfiability_subsetsum::canonical_rule_example_specs());

src/unit_tests/rules/analysis.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,11 @@ fn test_find_dominated_rules_returns_known_set() {
245245
("Factoring", "ILP {variable: \"i32\"}"),
246246
// K3-SAT → QUBO via SAT → CircuitSAT → SpinGlass chain
247247
("KSatisfiability {k: \"K3\"}", "QUBO {weight: \"f64\"}"),
248+
// GraphPartitioning -> MaxCut -> SpinGlass -> QUBO is better
249+
(
250+
"GraphPartitioning {graph: \"SimpleGraph\"}",
251+
"QUBO {weight: \"f64\"}",
252+
),
248253
// Knapsack -> ILP -> QUBO is better than the direct penalty reduction
249254
("Knapsack", "QUBO {weight: \"f64\"}"),
250255
// MaxMatching → MaxSetPacking → ILP is better than direct MaxMatching → ILP
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
use super::*;
2+
use crate::models::algebraic::QUBO;
3+
use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target;
4+
use crate::topology::SimpleGraph;
5+
6+
fn example_problem() -> GraphPartitioning<SimpleGraph> {
7+
GraphPartitioning::new(SimpleGraph::new(
8+
6,
9+
vec![
10+
(0, 1),
11+
(0, 2),
12+
(1, 2),
13+
(1, 3),
14+
(2, 3),
15+
(2, 4),
16+
(3, 4),
17+
(3, 5),
18+
(4, 5),
19+
],
20+
))
21+
}
22+
23+
#[test]
24+
fn test_graphpartitioning_to_qubo_closed_loop() {
25+
let source = example_problem();
26+
let reduction = ReduceTo::<QUBO<f64>>::reduce_to(&source);
27+
28+
assert_optimization_round_trip_from_optimization_target(
29+
&source,
30+
&reduction,
31+
"GraphPartitioning->QUBO closed loop",
32+
);
33+
}
34+
35+
#[test]
36+
fn test_graphpartitioning_to_qubo_matrix_matches_issue_example() {
37+
let source = example_problem();
38+
let reduction = ReduceTo::<QUBO<f64>>::reduce_to(&source);
39+
let qubo = reduction.target_problem();
40+
41+
assert_eq!(qubo.num_vars(), 6);
42+
43+
let expected_diagonal = [-48.0, -47.0, -46.0, -46.0, -47.0, -48.0];
44+
for (index, expected) in expected_diagonal.into_iter().enumerate() {
45+
assert_eq!(qubo.get(index, index), Some(&expected));
46+
}
47+
48+
let edge_pairs = [
49+
(0, 1),
50+
(0, 2),
51+
(1, 2),
52+
(1, 3),
53+
(2, 3),
54+
(2, 4),
55+
(3, 4),
56+
(3, 5),
57+
(4, 5),
58+
];
59+
for &(u, v) in &edge_pairs {
60+
assert_eq!(qubo.get(u, v), Some(&18.0), "edge ({u}, {v})");
61+
}
62+
63+
let non_edge_pairs = [(0, 3), (0, 4), (0, 5), (1, 4), (1, 5), (2, 5)];
64+
for &(u, v) in &non_edge_pairs {
65+
assert_eq!(qubo.get(u, v), Some(&20.0), "non-edge ({u}, {v})");
66+
}
67+
}
68+
69+
#[cfg(feature = "example-db")]
70+
#[test]
71+
fn test_graphpartitioning_to_qubo_canonical_example_spec() {
72+
let spec = canonical_rule_example_specs()
73+
.into_iter()
74+
.find(|spec| spec.id == "graphpartitioning_to_qubo")
75+
.expect("missing canonical GraphPartitioning -> QUBO example spec");
76+
let example = (spec.build)();
77+
78+
assert_eq!(example.source.problem, "GraphPartitioning");
79+
assert_eq!(example.target.problem, "QUBO");
80+
assert_eq!(example.target.instance["num_vars"], 6);
81+
assert!(!example.solutions.is_empty());
82+
}

0 commit comments

Comments
 (0)