Skip to content

Commit ca2110e

Browse files
GiggleLiuisPANNclaude
authored
Fix #123: [Rule] SteinerTree to ILP (#708)
* Add plan for #123: [Rule] SteinerTree to ILP * Add SteinerTree to ILP reduction * Document SteinerTree to ILP reduction * chore: remove plan file after implementation * Tighten SteinerTree->ILP guard to strictly positive weights Zero-weight edges can cause the ILP to return non-tree optimal solutions (redundant zero-cost cycles). Following SCIP-Jack's convention, require strictly positive weights — zero-weight edges should be contracted before reduction. Added regression test for zero-weight rejection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update paper: SteinerTree->ILP requires strictly positive weights Align the paper's construction and correctness proof with the implementation's tightened precondition. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove duplicate bib entries from merge conflict resolution Entries booth1975, booth1976, lawler1972, eppstein1992, chopra1996, kou1977, boothlueker1976 were duplicated during merge-with-main. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add remark on zero-weight edge exclusion in SteinerTree->ILP paper section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Xiwei Pan <xiwei.pan@connect.hkust-gz.edu.cn> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f3204d5 commit ca2110e

5 files changed

Lines changed: 325 additions & 0 deletions

File tree

docs/paper/reductions.typ

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5330,6 +5330,53 @@ The following reductions to Integer Linear Programming are straightforward formu
53305330
_Solution extraction._ For each edge $e$ at index $"idx"$, read $x_e = x^*_(k n + "idx")$. The source configuration is $"config"[e] = x_e$ (1 = cut, 0 = keep).
53315331
]
53325332

5333+
#let st_ilp = load-example("SteinerTree", "ILP")
5334+
#let st_ilp_sol = st_ilp.solutions.at(0)
5335+
#let st_edges = st_ilp.source.instance.graph.edges
5336+
#let st_weights = st_ilp.source.instance.edge_weights
5337+
#let st_terminals = st_ilp.source.instance.terminals
5338+
#let st_root = st_terminals.at(0)
5339+
#let st_non_root_terminals = range(1, st_terminals.len()).map(i => st_terminals.at(i))
5340+
#let st_selected_edge_indices = st_ilp_sol.source_config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i)
5341+
#let st_selected_edges = st_selected_edge_indices.map(i => st_edges.at(i))
5342+
#let st_cost = st_selected_edge_indices.map(i => st_weights.at(i)).sum()
5343+
5344+
#reduction-rule("SteinerTree", "ILP",
5345+
example: true,
5346+
example-caption: [Canonical Steiner tree instance ($n = #st_ilp.source.instance.graph.num_vertices$, $m = #st_edges.len()$, $|T| = #st_terminals.len()$)],
5347+
extra: [
5348+
*Step 1 -- Choose a root and one commodity per remaining terminal.* The canonical source instance has terminals $T = {#st_terminals.map(t => $v_#t$).join(", ")}$. The reduction fixes the first terminal as root $r = v_#st_root$ and creates one flow commodity for each remaining terminal: $v_#st_non_root_terminals.at(0)$ and $v_#st_non_root_terminals.at(1)$.
5349+
5350+
*Step 2 -- Count the variables from the source edge order.* The first #st_edges.len() target variables are the edge selectors $bold(y) = (#st_ilp_sol.target_config.slice(0, st_edges.len()).map(str).join(", "))$, one per source edge in the order #st_edges.enumerate().map(((i, e)) => [$e_#i = (#(e.at(0)), #(e.at(1)))$]).join(", "). The remaining #(st_ilp.target.instance.num_vars - st_edges.len()) variables are directed flow indicators: $2 m (|T| - 1) = 2 times #st_edges.len() times #st_non_root_terminals.len() = #(st_ilp.target.instance.num_vars - st_edges.len())$.
5351+
5352+
*Step 3 -- Count the constraints commodity-by-commodity.* Each non-root terminal contributes one flow-conservation equality per vertex and two capacity inequalities per source edge. For this fixture that is $#st_ilp.source.instance.graph.num_vertices times #st_non_root_terminals.len() = #(st_ilp.source.instance.graph.num_vertices * st_non_root_terminals.len())$ equalities plus $#(2 * st_edges.len()) times #st_non_root_terminals.len() = #(2 * st_edges.len() * st_non_root_terminals.len())$ inequalities, totaling #st_ilp.target.instance.constraints.len() constraints.
5353+
5354+
*Step 4 -- Read the canonical witness pair.* The source witness selects edges ${#st_selected_edges.map(e => $(v_#(e.at(0)), v_#(e.at(1)))$).join(", ")}$, so $bold(y)$ already encodes the Steiner tree. In the target witness, the commodity for $v_2$ routes along $v_0 arrow v_1 arrow v_2$, while the commodity for $v_4$ routes along $v_0 arrow v_1 arrow v_3 arrow v_4$. Every flow 1-entry therefore sits under a selected edge variable #sym.checkmark
5355+
5356+
*Step 5 -- Verify the objective end-to-end.* The selected-edge prefix is $bold(y) = (#st_ilp_sol.target_config.slice(0, st_edges.len()).map(str).join(", "))$, matching the source witness $(#st_ilp_sol.source_config.map(str).join(", "))$. The ILP objective is #st_selected_edge_indices.map(i => $#(st_weights.at(i))$).join($+$) $= #st_cost$, exactly the Steiner tree optimum stored in the fixture.
5357+
5358+
*Multiplicity:* The fixture stores one canonical witness. Other optimal Steiner trees could yield different feasible ILP witnesses, but every valid witness still exposes the source solution in the first $m$ variables.
5359+
],
5360+
)[
5361+
The rooted multi-commodity flow formulation @wong1984steiner @kochmartin1998steiner introduces one binary selector $y_e$ for each source edge and, for every non-root terminal $t$, one binary flow variable on each directed source edge. Flow conservation sends one unit from the root to each terminal, while the linking inequalities $f^t_(u,v) <= y_e$ ensure that every used flow arc is backed by a selected source edge. The resulting binary ILP has $m + 2 m (k - 1)$ variables and $n (k - 1) + 2 m (k - 1)$ constraints.
5362+
][
5363+
_Construction._ Given an undirected weighted graph $G = (V, E, w)$ with strictly positive edge weights, terminals $T = {t_0, dots, t_(k-1)}$, and root $r = t_0$, introduce binary edge selectors $y_e in {0,1}$ for every $e in E$. For each non-root terminal $t in T backslash {r}$ and each directed copy of an undirected edge $(u, v) in E$, introduce a binary flow variable $f^t_(u,v) in {0,1}$. The target objective is
5364+
$ min sum_(e in E) w_e y_e. $
5365+
For every commodity $t$ and vertex $v$, enforce flow conservation:
5366+
$ sum_(u : (u, v) in A) f^t_(u,v) - sum_(u : (v, u) in A) f^t_(v,u) = b_(t,v), $
5367+
where $A$ contains both orientations of every undirected edge, $b_(t,v) = -1$ at the root $v = r$, $b_(t,v) = 1$ at the sink $v = t$, and $b_(t,v) = 0$ otherwise. For every commodity $t$ and undirected edge $e = {u, v}$, add the capacity-linking inequalities
5368+
$ f^t_(u,v) <= y_e quad "and" quad f^t_(v,u) <= y_e. $
5369+
Binary flow variables suffice because any Steiner tree yields a unique simple root-to-terminal path for each commodity, so every commodity can be realized as a 0/1 path indicator.
5370+
5371+
_Correctness._ ($arrow.r.double$) If $S subset.eq E$ is a Steiner tree, set $y_e = 1$ exactly for $e in S$. For each non-root terminal $t$, the unique path from $r$ to $t$ inside the tree defines a binary flow assignment satisfying the conservation equations, and every used arc lies on a selected edge, so all linking inequalities hold. The ILP objective equals $sum_(e in S) w_e$. ($arrow.l.double$) Any feasible ILP solution with edge selector set $Y = {e in E : y_e = 1}$ supports one unit of flow from $r$ to every non-root terminal, so the selected edges contain a connected subgraph spanning all terminals. Because all edge weights are strictly positive, any cycle in the selected subgraph has positive total cost; the optimizer therefore never includes redundant edges, so the selected subgraph is already a Steiner tree. Therefore an optimal ILP solution induces a minimum-cost Steiner tree.
5372+
5373+
_Variable mapping._ The first $m$ ILP variables are the source-edge indicators $y_0, dots, y_(m-1)$ in source edge order. For terminal $t_p$ with $p in {1, dots, k-1}$, the next block of $2 m$ variables stores the directed arc indicators $f^(t_p)_(u,v)$ and $f^(t_p)_(v,u)$ for each source edge $(u, v)$.
5374+
5375+
_Solution extraction._ Read the first $m$ target variables as the source edge-selection vector. Since those coordinates are exactly the $y_e$ variables, the extracted source configuration is valid whenever the selected subgraph is pruned to its Steiner tree witness.
5376+
5377+
_Remark._ Zero-weight edges are excluded because they allow degenerate optimal ILP solutions containing redundant cycles at no cost; following the convention of practical solvers (e.g., SCIP-Jack @kochmartin1998steiner), such edges should be contracted before applying the reduction.
5378+
]
5379+
53335380
== Unit Disk Mapping
53345381

53355382
#reduction-rule("MaximumIndependentSet", "KingsSubgraph")[

docs/paper/references.bib

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,28 @@ @article{schaefer1978
11881188
doi = {10.1145/800133.804350}
11891189
}
11901190

1191+
@article{wong1984steiner,
1192+
author = {R. T. Wong},
1193+
title = {A Dual Ascent Approach for Steiner Tree Problems on a Directed Graph},
1194+
journal = {Mathematical Programming},
1195+
volume = {28},
1196+
number = {3},
1197+
pages = {271--287},
1198+
year = {1984},
1199+
doi = {10.1007/BF02612335}
1200+
}
1201+
1202+
@article{kochmartin1998steiner,
1203+
author = {Thorsten Koch and Alexander Martin},
1204+
title = {Solving Steiner Tree Problems in Graphs to Optimality},
1205+
journal = {Networks},
1206+
volume = {32},
1207+
number = {3},
1208+
pages = {207--232},
1209+
year = {1998},
1210+
doi = {10.1002/(SICI)1097-0037(199810)32:3<207::AID-NET5>3.0.CO;2-O}
1211+
}
1212+
11911213
@inproceedings{stockmeyer1973,
11921214
author = {Larry J. Stockmeyer and Albert R. Meyer},
11931215
title = {Word Problems Requiring Exponential Time: Preliminary Report},

src/rules/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ pub(crate) mod qubo_ilp;
8282
#[cfg(feature = "ilp-solver")]
8383
pub(crate) mod sequencingtominimizeweightedcompletiontime_ilp;
8484
#[cfg(feature = "ilp-solver")]
85+
pub(crate) mod steinertree_ilp;
86+
#[cfg(feature = "ilp-solver")]
8587
pub(crate) mod travelingsalesman_ilp;
8688

8789
pub use graph::{
@@ -138,6 +140,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::Ru
138140
specs.extend(qubo_ilp::canonical_rule_example_specs());
139141
specs
140142
.extend(sequencingtominimizeweightedcompletiontime_ilp::canonical_rule_example_specs());
143+
specs.extend(steinertree_ilp::canonical_rule_example_specs());
141144
specs.extend(travelingsalesman_ilp::canonical_rule_example_specs());
142145
}
143146
specs

src/rules/steinertree_ilp.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
//! Reduction from SteinerTree to ILP (Integer Linear Programming).
2+
//!
3+
//! Uses the standard rooted multi-commodity flow formulation:
4+
//! - Variables: edge selectors `y_e` plus directed flow variables `f^t_(u,v)`
5+
//! for each non-root terminal `t`
6+
//! - Constraints: flow conservation for each commodity and capacity linking
7+
//! `f^t_(u,v) <= y_e`
8+
//! - Objective: minimize the total weight of selected edges
9+
10+
use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP};
11+
use crate::models::graph::SteinerTree;
12+
use crate::reduction;
13+
use crate::rules::traits::{ReduceTo, ReductionResult};
14+
use crate::topology::{Graph, SimpleGraph};
15+
16+
/// Result of reducing SteinerTree to ILP.
17+
///
18+
/// Variable layout (all binary):
19+
/// - `y_e` for each undirected source edge `e` (indices `0..m`)
20+
/// - `f^t_(u,v)` and `f^t_(v,u)` for each non-root terminal `t` and each source edge
21+
/// `(u, v)` (indices `m..m + 2m(k-1)`)
22+
#[derive(Debug, Clone)]
23+
pub struct ReductionSteinerTreeToILP {
24+
target: ILP<bool>,
25+
num_edges: usize,
26+
}
27+
28+
impl ReductionResult for ReductionSteinerTreeToILP {
29+
type Source = SteinerTree<SimpleGraph, i32>;
30+
type Target = ILP<bool>;
31+
32+
fn target_problem(&self) -> &ILP<bool> {
33+
&self.target
34+
}
35+
36+
fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
37+
target_solution[..self.num_edges].to_vec()
38+
}
39+
}
40+
41+
#[reduction(
42+
overhead = {
43+
num_vars = "num_edges + 2 * num_edges * (num_terminals - 1)",
44+
num_constraints = "num_vertices * (num_terminals - 1) + 2 * num_edges * (num_terminals - 1)",
45+
}
46+
)]
47+
impl ReduceTo<ILP<bool>> for SteinerTree<SimpleGraph, i32> {
48+
type Result = ReductionSteinerTreeToILP;
49+
50+
fn reduce_to(&self) -> Self::Result {
51+
assert!(
52+
self.edge_weights().iter().all(|&weight| weight > 0),
53+
"SteinerTree -> ILP requires strictly positive edge weights (zero-weight edges should be contracted beforehand)"
54+
);
55+
56+
let n = self.num_vertices();
57+
let m = self.num_edges();
58+
let root = self.terminals()[0];
59+
let non_root_terminals = &self.terminals()[1..];
60+
let edges = self.graph().edges();
61+
let num_vars = m + 2 * m * non_root_terminals.len();
62+
let num_constraints = n * non_root_terminals.len() + 2 * m * non_root_terminals.len();
63+
let mut constraints = Vec::with_capacity(num_constraints);
64+
65+
let edge_var = |edge_idx: usize| edge_idx;
66+
let flow_var = |terminal_pos: usize, edge_idx: usize, dir: usize| -> usize {
67+
m + terminal_pos * 2 * m + 2 * edge_idx + dir
68+
};
69+
70+
for (terminal_pos, &terminal) in non_root_terminals.iter().enumerate() {
71+
for vertex in 0..n {
72+
let mut terms = Vec::new();
73+
for (edge_idx, &(u, v)) in edges.iter().enumerate() {
74+
if v == vertex {
75+
terms.push((flow_var(terminal_pos, edge_idx, 0), 1.0));
76+
terms.push((flow_var(terminal_pos, edge_idx, 1), -1.0));
77+
}
78+
if u == vertex {
79+
terms.push((flow_var(terminal_pos, edge_idx, 0), -1.0));
80+
terms.push((flow_var(terminal_pos, edge_idx, 1), 1.0));
81+
}
82+
}
83+
84+
let rhs = if vertex == root {
85+
-1.0
86+
} else if vertex == terminal {
87+
1.0
88+
} else {
89+
0.0
90+
};
91+
constraints.push(LinearConstraint::eq(terms, rhs));
92+
}
93+
}
94+
95+
for terminal_pos in 0..non_root_terminals.len() {
96+
for edge_idx in 0..m {
97+
let selector = edge_var(edge_idx);
98+
constraints.push(LinearConstraint::le(
99+
vec![(flow_var(terminal_pos, edge_idx, 0), 1.0), (selector, -1.0)],
100+
0.0,
101+
));
102+
constraints.push(LinearConstraint::le(
103+
vec![(flow_var(terminal_pos, edge_idx, 1), 1.0), (selector, -1.0)],
104+
0.0,
105+
));
106+
}
107+
}
108+
109+
let objective: Vec<(usize, f64)> = self
110+
.edge_weights()
111+
.iter()
112+
.enumerate()
113+
.map(|(edge_idx, &weight)| (edge_var(edge_idx), weight as f64))
114+
.collect();
115+
116+
let target = ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize);
117+
118+
ReductionSteinerTreeToILP {
119+
target,
120+
num_edges: m,
121+
}
122+
}
123+
}
124+
125+
#[cfg(feature = "example-db")]
126+
pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::RuleExampleSpec> {
127+
use crate::export::SolutionPair;
128+
129+
vec![crate::example_db::specs::RuleExampleSpec {
130+
id: "steinertree_to_ilp",
131+
build: || {
132+
let source = SteinerTree::new(
133+
SimpleGraph::new(
134+
5,
135+
vec![(0, 1), (1, 2), (1, 3), (3, 4), (0, 3), (3, 2), (2, 4)],
136+
),
137+
vec![2, 2, 1, 1, 5, 5, 6],
138+
vec![0, 2, 4],
139+
);
140+
crate::example_db::specs::rule_example_with_witness::<_, ILP<bool>>(
141+
source,
142+
SolutionPair {
143+
source_config: vec![1, 1, 1, 1, 0, 0, 0],
144+
target_config: vec![
145+
1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0,
146+
1, 0, 1, 0, 0, 0, 0, 0, 0, 0,
147+
],
148+
},
149+
)
150+
},
151+
}]
152+
}
153+
154+
#[cfg(test)]
155+
#[path = "../unit_tests/rules/steinertree_ilp.rs"]
156+
mod tests;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
use super::*;
2+
use crate::models::algebraic::{ObjectiveSense, ILP};
3+
use crate::models::graph::SteinerTree;
4+
use crate::rules::ReduceTo;
5+
use crate::solvers::{BruteForce, ILPSolver};
6+
use crate::topology::SimpleGraph;
7+
use crate::traits::Problem;
8+
use crate::types::SolutionSize;
9+
10+
fn canonical_instance() -> SteinerTree<SimpleGraph, i32> {
11+
let graph = SimpleGraph::new(
12+
5,
13+
vec![(0, 1), (1, 2), (1, 3), (3, 4), (0, 3), (3, 2), (2, 4)],
14+
);
15+
SteinerTree::new(graph, vec![2, 2, 1, 1, 5, 5, 6], vec![0, 2, 4])
16+
}
17+
18+
#[test]
19+
fn test_reduction_creates_expected_ilp_shape() {
20+
let problem = canonical_instance();
21+
let reduction: ReductionSteinerTreeToILP = ReduceTo::<ILP<bool>>::reduce_to(&problem);
22+
let ilp = reduction.target_problem();
23+
24+
assert_eq!(ilp.num_vars, 35);
25+
assert_eq!(ilp.constraints.len(), 38);
26+
assert_eq!(ilp.sense, ObjectiveSense::Minimize);
27+
assert_eq!(
28+
ilp.objective,
29+
vec![
30+
(0, 2.0),
31+
(1, 2.0),
32+
(2, 1.0),
33+
(3, 1.0),
34+
(4, 5.0),
35+
(5, 5.0),
36+
(6, 6.0),
37+
]
38+
);
39+
}
40+
41+
#[test]
42+
fn test_steinertree_to_ilp_closed_loop() {
43+
let problem = canonical_instance();
44+
let reduction: ReductionSteinerTreeToILP = ReduceTo::<ILP<bool>>::reduce_to(&problem);
45+
let ilp = reduction.target_problem();
46+
47+
let bf = BruteForce::new();
48+
let ilp_solver = ILPSolver::new();
49+
let best_source = bf.find_all_best(&problem);
50+
let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable");
51+
let extracted = reduction.extract_solution(&ilp_solution);
52+
53+
assert_eq!(problem.evaluate(&best_source[0]), SolutionSize::Valid(6));
54+
assert_eq!(problem.evaluate(&extracted), SolutionSize::Valid(6));
55+
assert!(problem.is_valid_solution(&extracted));
56+
}
57+
58+
#[test]
59+
fn test_solution_extraction_reads_edge_selector_prefix() {
60+
let problem = canonical_instance();
61+
let reduction: ReductionSteinerTreeToILP = ReduceTo::<ILP<bool>>::reduce_to(&problem);
62+
63+
let target_solution = vec![
64+
1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0,
65+
0, 0, 0, 0, 0,
66+
];
67+
68+
assert_eq!(
69+
reduction.extract_solution(&target_solution),
70+
vec![1, 1, 1, 1, 0, 0, 0]
71+
);
72+
}
73+
74+
#[test]
75+
fn test_solve_reduced_uses_new_rule() {
76+
let problem = canonical_instance();
77+
let solution = ILPSolver::new()
78+
.solve_reduced(&problem)
79+
.expect("solve_reduced should find the Steiner tree via ILP");
80+
assert_eq!(problem.evaluate(&solution), SolutionSize::Valid(6));
81+
}
82+
83+
#[test]
84+
#[should_panic(expected = "SteinerTree -> ILP requires strictly positive edge weights")]
85+
fn test_reduction_rejects_negative_weights() {
86+
let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]);
87+
let problem = SteinerTree::new(graph, vec![1, -2, 3], vec![0, 1]);
88+
let _ = ReduceTo::<ILP<bool>>::reduce_to(&problem);
89+
}
90+
91+
#[test]
92+
#[should_panic(expected = "SteinerTree -> ILP requires strictly positive edge weights")]
93+
fn test_reduction_rejects_zero_weights() {
94+
let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]);
95+
let problem = SteinerTree::new(graph, vec![0, 0, 0], vec![0, 1]);
96+
let _ = ReduceTo::<ILP<bool>>::reduce_to(&problem);
97+
}

0 commit comments

Comments
 (0)