Skip to content

Commit 54aca98

Browse files
GiggleLiuclaude
andauthored
Fix #118: [Rule] GraphPartitioning to ILP (#705)
* Add plan for #118: [Rule] GraphPartitioning to ILP * Add GraphPartitioning to ILP reduction * Document GraphPartitioning to ILP reduction * chore: remove plan file after implementation * Fix import order in graphpartitioning_ilp.rs for rustfmt compliance Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove duplicate BibTeX entries introduced during merge The merge with main duplicated chopra1996, eppstein1992, kou1977, and lawler1972 entries. Keep the originals and remove duplicates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix formatting after merge with main Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Restore chopra1996 bib entry needed by existing paper content 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 68c744b commit 54aca98

6 files changed

Lines changed: 310 additions & 16 deletions

File tree

.claude/skills/final-review/SKILL.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,12 @@ git fetch origin main
8787
git merge origin/main --no-edit
8888
```
8989

90-
- **Merge clean** — merge commit is ready locally. Run `cargo check` to verify the merge didn't introduce API incompatibilities (e.g., functions renamed/removed on main that the PR still calls). If `cargo check` fails, fix the compile errors before proceeding.
91-
- **Merge conflicted** — most conflicts in this codebase are "both sides added new entries in ordered lists" (in `mod.rs`, `lib.rs`, `create.rs`, `dispatch.rs`, `reductions.typ`). These are mechanical: keep both sides, maintain alphabetical order. Delegate to a subagent for resolution, then run `cargo check` to verify. Continue with the review; if conflicts are too complex, decide whether to hold in Step 5.
90+
- **Merge clean** — merge commit is ready locally. Run `make check && make paper` to verify the merge didn't break anything (API incompatibilities, formatting, test failures, paper compilation). If any step fails, fix the errors before proceeding. For `.bib` conflicts, also check for duplicate BibTeX keys (`grep '^@' docs/paper/references.bib | sed 's/@[a-z]*{//' | sed 's/,$//' | sort | uniq -d`) and remove duplicates.
91+
- **Merge conflicted** — most conflicts in this codebase are "both sides added new entries in ordered lists" (in `mod.rs`, `lib.rs`, `create.rs`, `dispatch.rs`, `reductions.typ`). These are mechanical: keep both sides, maintain alphabetical order. Delegate to a subagent for resolution, then run `make check && make paper` to verify. Continue with the review; if conflicts are too complex, decide whether to hold in Step 5.
9292
- **Merge failed** — note the error and continue.
9393

9494
**0d. Sanity check**: verify the diff touches `src/models/` or `src/rules/` (for model/rule PRs). If the diff only contains unrelated files, STOP and flag the mismatch.
9595

96-
**0e. Documentation build check**: if the PR touches `docs/paper/reductions.typ`, `docs/paper/references.bib`, or example specs, run `make paper` to verify the Typst paper compiles with the merged code. If compilation fails, check whether the error is in files touched by this PR. If not, note "pre-existing paper error from [source model/rule]" and continue — do not block the review on errors from other merged PRs. Only fix errors caused by this PR.
97-
9896
### Step 1: Gather Context
9997

10098
Use `gh` commands to get the PR's actual data — always from GitHub, never from local git state:
@@ -380,12 +378,13 @@ Use `AskUserQuestion` only when needed:
380378
### Step 8: Execute decision
381379

382380
**If Push and fix CI:**
383-
1. Push all commits (merge-with-main + any fixes) from the worktree:
381+
1. **Pre-push verification** — run `make check && make paper` locally before pushing. Both must pass. Fix any failures, commit, then push:
384382
```bash
385383
cd <worktree path>
384+
make check && make paper
386385
git push
387386
```
388-
2. Wait for CI — but **do not poll excessively**. Check once after ~60 seconds. If CI hasn't triggered or is still pending, run `make check` locally (fmt + clippy + test). If local checks pass, proceed to step 4 immediately and note "CI pending, local checks pass" when presenting the merge link. The reviewer can admin-merge or wait for CI at their discretion. Do not spend more than 1–2 CI poll attempts. If CI does run and fails, fix the issues, commit, and push again.
387+
2. Wait for CI — but **do not poll excessively**. Check once after ~60 seconds. If CI hasn't triggered or is still pending, note "CI pending, local checks pass" when presenting the merge link (since pre-push already verified). The reviewer can admin-merge or wait for CI at their discretion. Do not spend more than 1–2 CI poll attempts. If CI does run and fails, fix the issues, commit, and push again.
389388
3. If any follow-up items were noted during the review, post them as a comment:
390389
```bash
391390
COMMENT_FILE=$(mktemp)

docs/paper/reductions.typ

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5079,6 +5079,41 @@ The following reductions to Integer Linear Programming are straightforward formu
50795079
_Solution extraction._ $K = {v : x_v = 1}$.
50805080
]
50815081

5082+
#let gp_ilp = load-example("GraphPartitioning", "ILP")
5083+
#let gp_ilp_sol = gp_ilp.solutions.at(0)
5084+
#let gp_n = graph-num-vertices(gp_ilp.source.instance)
5085+
#let gp_edges = gp_ilp.source.instance.graph.edges
5086+
#let gp_m = gp_edges.len()
5087+
#let gp_part_a = range(gp_n).filter(i => gp_ilp_sol.source_config.at(i) == 0)
5088+
#let gp_part_b = range(gp_n).filter(i => gp_ilp_sol.source_config.at(i) == 1)
5089+
#let gp_crossing = range(gp_m).filter(i => gp_ilp_sol.target_config.at(gp_n + i) == 1)
5090+
#let gp_crossing_edges = gp_crossing.map(i => gp_edges.at(i))
5091+
#reduction-rule("GraphPartitioning", "ILP",
5092+
example: true,
5093+
example-caption: [Two triangles linked by three crossing edges encoded as a 15-variable ILP.],
5094+
extra: [
5095+
*Step 1 -- Balanced partition variables.* Introduce $x_v in {0,1}$ for each vertex. In the canonical witness, $A = {#gp_part_a.map(str).join(", ")}$ and $B = {#gp_part_b.map(str).join(", ")}$, so $bold(x) = (#gp_ilp_sol.source_config.map(str).join(", "))$.\
5096+
5097+
*Step 2 -- Crossing indicators.* Add one binary variable per edge, so the target has $#gp_ilp.target.instance.num_vars$ binary variables and #gp_ilp.target.instance.constraints.len() constraints in total. The three active crossing indicators correspond to edges $\{#gp_crossing_edges.map(e => "(" + str(e.at(0)) + "," + str(e.at(1)) + ")").join(", ")\}$.\
5098+
5099+
*Step 3 -- Verify the objective.* The target witness $bold(z) = (#gp_ilp_sol.target_config.map(str).join(", "))$ sets exactly #gp_crossing.len() edge-indicator variables to 1, so the ILP objective equals the bisection width #gp_crossing.len() #sym.checkmark
5100+
],
5101+
)[
5102+
The node-and-edge integer-programming formulation of Chopra and Rao @chopra1993 models a balanced cut with one binary variable per vertex and one binary crossing indicator per edge. A single balance equality enforces the bisection, and two linear inequalities per edge linearize $|x_u - x_v|$ so that the objective can minimize the number of crossing edges directly.
5103+
][
5104+
_Construction._ Given graph $G = (V, E)$ with $n = |V|$ and $m = |E|$:
5105+
5106+
_Variables._ Binary $x_v in {0, 1}$ for each $v in V$, where $x_v = 1$ means vertex $v$ is placed in side $B$. For each edge $e = (u, v) in E$, binary $y_e in {0, 1}$ indicates whether $e$ crosses the partition. Total: $n + m$ variables.
5107+
5108+
_Constraints._ (1) Balance: $sum_(v in V) x_v = n / 2$. If $n$ is odd, the right-hand side is fractional, so the ILP is infeasible exactly when Graph Partitioning has no valid balanced partition. (2) For each edge $e = (u, v)$: $y_e >= x_u - x_v$ and $y_e >= x_v - x_u$. Since $y_e$ is binary and the objective minimizes $sum_e y_e$, these inequalities force $y_e = 1$ exactly for crossing edges. Total: $2m + 1$ constraints.
5109+
5110+
_Objective._ Minimize $sum_(e in E) y_e$.
5111+
5112+
_Correctness._ ($arrow.r.double$) Given a balanced partition $(A, B)$, set $x_v = 1$ iff $v in B$, and set $y_e = 1$ iff edge $e$ has one endpoint in each side. The balance constraint holds because $|B| = n / 2$, and the linking inequalities hold because $|x_u - x_v| = 1$ exactly on crossing edges. The objective is therefore the cut size. ($arrow.l.double$) Any feasible ILP solution satisfies the balance equation, so exactly half the vertices have $x_v = 1$ when $n$ is even. For each edge, the linking inequalities imply $y_e >= |x_u - x_v|$; minimization therefore chooses $y_e = |x_u - x_v|$, making the objective count precisely the crossing edges of the extracted partition.
5113+
5114+
_Solution extraction._ Return the first $n$ variables $(x_v)_(v in V)$ as the Graph Partitioning configuration; the edge-indicator variables are auxiliary.
5115+
]
5116+
50825117
#let ks_ilp = load-example("Knapsack", "ILP")
50835118
#let ks_ilp_sol = ks_ilp.solutions.at(0)
50845119
#let ks_ilp_selected = ks_ilp_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => i)

docs/paper/references.bib

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,27 @@ @phdthesis{booth1975
894894
year = {1975}
895895
}
896896

897+
@article{chopra1993,
898+
author = {Sunil Chopra and M. R. Rao},
899+
title = {The partition problem},
900+
journal = {Mathematical Programming},
901+
volume = {59},
902+
number = {1--3},
903+
pages = {87--115},
904+
year = {1993},
905+
doi = {10.1007/BF01581239}
906+
}
907+
908+
@article{chopra1996,
909+
author = {Sunil Chopra and Jonathan H. Owen},
910+
title = {Extended formulations for the A-cut problem},
911+
journal = {Mathematical Programming},
912+
volume = {73},
913+
pages = {7--30},
914+
year = {1996},
915+
doi = {10.1007/BF02592096}
916+
}
917+
897918
@article{boothlueker1976,
898919
author = {Kellogg S. Booth and George S. Lueker},
899920
title = {Testing for the Consecutive Ones Property, Interval Graphs, and Graph Planarity Using {PQ}-Tree Algorithms},
@@ -925,16 +946,6 @@ @article{chen2008
925946
doi = {10.1145/1411509.1411511}
926947
}
927948

928-
@article{chopra1996,
929-
author = {Sunil Chopra and Jonathan H. Owen},
930-
title = {Extended formulations for the A-cut problem},
931-
journal = {Mathematical Programming},
932-
volume = {73},
933-
pages = {7--30},
934-
year = {1996},
935-
doi = {10.1007/BF02592096}
936-
}
937-
938949
@article{coffman1972,
939950
author = {Edward G. Coffman and Ronald L. Graham},
940951
title = {Optimal Scheduling for Two-Processor Systems},

src/rules/graphpartitioning_ilp.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//! Reduction from GraphPartitioning to ILP (Integer Linear Programming).
2+
//!
3+
//! Uses the standard balanced-cut ILP formulation:
4+
//! - Variables: `x_v` for vertex-side assignment and `y_e` for edge-crossing indicators
5+
//! - Constraints: one balance equality plus two linking inequalities per edge
6+
//! - Objective: minimize the number of crossing edges
7+
8+
use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP};
9+
use crate::models::graph::GraphPartitioning;
10+
use crate::reduction;
11+
use crate::rules::traits::{ReduceTo, ReductionResult};
12+
use crate::topology::{Graph, SimpleGraph};
13+
14+
/// Result of reducing GraphPartitioning to ILP.
15+
///
16+
/// Variable layout (all binary):
17+
/// - `x_v` for `v = 0..n-1`: vertex `v` belongs to side `B`
18+
/// - `y_e` for `e = 0..m-1`: edge `e` crosses the partition
19+
#[derive(Debug, Clone)]
20+
pub struct ReductionGraphPartitioningToILP {
21+
target: ILP<bool>,
22+
num_vertices: usize,
23+
}
24+
25+
impl ReductionResult for ReductionGraphPartitioningToILP {
26+
type Source = GraphPartitioning<SimpleGraph>;
27+
type Target = ILP<bool>;
28+
29+
fn target_problem(&self) -> &ILP<bool> {
30+
&self.target
31+
}
32+
33+
fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
34+
target_solution[..self.num_vertices].to_vec()
35+
}
36+
}
37+
38+
#[reduction(
39+
overhead = {
40+
num_vars = "num_vertices + num_edges",
41+
num_constraints = "2 * num_edges + 1",
42+
}
43+
)]
44+
impl ReduceTo<ILP<bool>> for GraphPartitioning<SimpleGraph> {
45+
type Result = ReductionGraphPartitioningToILP;
46+
47+
fn reduce_to(&self) -> Self::Result {
48+
let n = self.num_vertices();
49+
let edges = self.graph().edges();
50+
let m = edges.len();
51+
let num_vars = n + m;
52+
53+
let mut constraints = Vec::with_capacity(2 * m + 1);
54+
55+
let balance_terms: Vec<(usize, f64)> = (0..n).map(|v| (v, 1.0)).collect();
56+
constraints.push(LinearConstraint::eq(balance_terms, n as f64 / 2.0));
57+
58+
for (edge_idx, (u, v)) in edges.iter().enumerate() {
59+
let y_var = n + edge_idx;
60+
constraints.push(LinearConstraint::ge(
61+
vec![(y_var, 1.0), (*u, -1.0), (*v, 1.0)],
62+
0.0,
63+
));
64+
constraints.push(LinearConstraint::ge(
65+
vec![(y_var, 1.0), (*u, 1.0), (*v, -1.0)],
66+
0.0,
67+
));
68+
}
69+
70+
let objective: Vec<(usize, f64)> = (0..m).map(|edge_idx| (n + edge_idx, 1.0)).collect();
71+
let target = ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize);
72+
73+
ReductionGraphPartitioningToILP {
74+
target,
75+
num_vertices: n,
76+
}
77+
}
78+
}
79+
80+
#[cfg(feature = "example-db")]
81+
pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
82+
use crate::export::SolutionPair;
83+
84+
vec![crate::example_db::specs::RuleExampleSpec {
85+
id: "graphpartitioning_to_ilp",
86+
build: || {
87+
let source = GraphPartitioning::new(SimpleGraph::new(
88+
6,
89+
vec![
90+
(0, 1),
91+
(0, 2),
92+
(1, 2),
93+
(1, 3),
94+
(2, 3),
95+
(2, 4),
96+
(3, 4),
97+
(3, 5),
98+
(4, 5),
99+
],
100+
));
101+
crate::example_db::specs::rule_example_with_witness::<_, ILP<bool>>(
102+
source,
103+
SolutionPair {
104+
source_config: vec![0, 0, 0, 1, 1, 1],
105+
target_config: vec![0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0],
106+
},
107+
)
108+
},
109+
}]
110+
}
111+
112+
#[cfg(test)]
113+
#[path = "../unit_tests/rules/graphpartitioning_ilp.rs"]
114+
mod tests;

src/rules/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ pub(crate) mod coloring_ilp;
5353
#[cfg(feature = "ilp-solver")]
5454
pub(crate) mod factoring_ilp;
5555
#[cfg(feature = "ilp-solver")]
56+
pub(crate) mod graphpartitioning_ilp;
57+
#[cfg(feature = "ilp-solver")]
5658
mod ilp_bool_ilp_i32;
5759
#[cfg(feature = "ilp-solver")]
5860
pub(crate) mod ilp_qubo;
@@ -120,6 +122,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::Ru
120122
specs.extend(circuit_ilp::canonical_rule_example_specs());
121123
specs.extend(coloring_ilp::canonical_rule_example_specs());
122124
specs.extend(factoring_ilp::canonical_rule_example_specs());
125+
specs.extend(graphpartitioning_ilp::canonical_rule_example_specs());
123126
specs.extend(ilp_qubo::canonical_rule_example_specs());
124127
specs.extend(knapsack_ilp::canonical_rule_example_specs());
125128
specs.extend(longestcommonsubsequence_ilp::canonical_rule_example_specs());
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
use super::*;
2+
use crate::models::algebraic::{Comparison, ObjectiveSense};
3+
use crate::models::graph::GraphPartitioning;
4+
use crate::solvers::{BruteForce, ILPSolver};
5+
use crate::topology::SimpleGraph;
6+
use crate::traits::Problem;
7+
use crate::types::SolutionSize;
8+
9+
fn canonical_instance() -> GraphPartitioning<SimpleGraph> {
10+
let graph = SimpleGraph::new(
11+
6,
12+
vec![
13+
(0, 1),
14+
(0, 2),
15+
(1, 2),
16+
(1, 3),
17+
(2, 3),
18+
(2, 4),
19+
(3, 4),
20+
(3, 5),
21+
(4, 5),
22+
],
23+
);
24+
GraphPartitioning::new(graph)
25+
}
26+
27+
#[test]
28+
fn test_reduction_creates_valid_ilp() {
29+
let problem = canonical_instance();
30+
let reduction: ReductionGraphPartitioningToILP = ReduceTo::<ILP<bool>>::reduce_to(&problem);
31+
let ilp = reduction.target_problem();
32+
33+
assert_eq!(ilp.num_vars, 15);
34+
assert_eq!(ilp.constraints.len(), 19);
35+
assert_eq!(ilp.sense, ObjectiveSense::Minimize);
36+
assert_eq!(
37+
ilp.objective,
38+
vec![
39+
(6, 1.0),
40+
(7, 1.0),
41+
(8, 1.0),
42+
(9, 1.0),
43+
(10, 1.0),
44+
(11, 1.0),
45+
(12, 1.0),
46+
(13, 1.0),
47+
(14, 1.0),
48+
]
49+
);
50+
}
51+
52+
#[test]
53+
fn test_reduction_constraint_shape() {
54+
let problem = GraphPartitioning::new(SimpleGraph::new(2, vec![(0, 1)]));
55+
let reduction: ReductionGraphPartitioningToILP = ReduceTo::<ILP<bool>>::reduce_to(&problem);
56+
let ilp = reduction.target_problem();
57+
58+
assert_eq!(ilp.num_vars, 3);
59+
assert_eq!(ilp.constraints.len(), 3);
60+
61+
let balance = &ilp.constraints[0];
62+
assert_eq!(balance.cmp, Comparison::Eq);
63+
assert_eq!(balance.terms, vec![(0, 1.0), (1, 1.0)]);
64+
assert_eq!(balance.rhs, 1.0);
65+
66+
let first_link = &ilp.constraints[1];
67+
assert_eq!(first_link.cmp, Comparison::Ge);
68+
assert_eq!(first_link.terms, vec![(2, 1.0), (0, -1.0), (1, 1.0)]);
69+
assert_eq!(first_link.rhs, 0.0);
70+
71+
let second_link = &ilp.constraints[2];
72+
assert_eq!(second_link.cmp, Comparison::Ge);
73+
assert_eq!(second_link.terms, vec![(2, 1.0), (0, 1.0), (1, -1.0)]);
74+
assert_eq!(second_link.rhs, 0.0);
75+
}
76+
77+
#[test]
78+
fn test_graphpartitioning_to_ilp_closed_loop() {
79+
let problem = canonical_instance();
80+
let reduction: ReductionGraphPartitioningToILP = ReduceTo::<ILP<bool>>::reduce_to(&problem);
81+
let ilp = reduction.target_problem();
82+
83+
let bf = BruteForce::new();
84+
let ilp_solver = ILPSolver::new();
85+
86+
let bf_solutions = bf.find_all_best(&problem);
87+
let bf_obj = problem.evaluate(&bf_solutions[0]);
88+
89+
let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable");
90+
let extracted = reduction.extract_solution(&ilp_solution);
91+
let ilp_obj = problem.evaluate(&extracted);
92+
93+
assert_eq!(bf_obj, SolutionSize::Valid(3));
94+
assert_eq!(ilp_obj, SolutionSize::Valid(3));
95+
}
96+
97+
#[test]
98+
fn test_odd_vertices_reduce_to_infeasible_ilp() {
99+
let problem = GraphPartitioning::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]));
100+
let reduction: ReductionGraphPartitioningToILP = ReduceTo::<ILP<bool>>::reduce_to(&problem);
101+
let ilp = reduction.target_problem();
102+
103+
assert_eq!(ilp.constraints[0].cmp, Comparison::Eq);
104+
assert_eq!(ilp.constraints[0].rhs, 1.5);
105+
106+
let solver = ILPSolver::new();
107+
assert_eq!(solver.solve(ilp), None);
108+
}
109+
110+
#[test]
111+
fn test_solution_extraction() {
112+
let problem = canonical_instance();
113+
let reduction: ReductionGraphPartitioningToILP = ReduceTo::<ILP<bool>>::reduce_to(&problem);
114+
115+
let ilp_solution = vec![0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0];
116+
let extracted = reduction.extract_solution(&ilp_solution);
117+
118+
assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]);
119+
assert_eq!(problem.evaluate(&extracted), SolutionSize::Valid(3));
120+
}
121+
122+
#[test]
123+
fn test_solve_reduced() {
124+
let problem = canonical_instance();
125+
126+
let ilp_solver = ILPSolver::new();
127+
let solution = ilp_solver
128+
.solve_reduced(&problem)
129+
.expect("solve_reduced should work");
130+
131+
assert_eq!(problem.evaluate(&solution), SolutionSize::Valid(3));
132+
}

0 commit comments

Comments
 (0)