From 4fb996c1debb6b099ec1b3d726897b66f1f98996 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 14:50:34 +0800 Subject: [PATCH 1/9] Add plan for #123: [Rule] SteinerTree to ILP --- docs/plans/2026-03-20-steinertree-to-ilp.md | 454 ++++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 docs/plans/2026-03-20-steinertree-to-ilp.md diff --git a/docs/plans/2026-03-20-steinertree-to-ilp.md b/docs/plans/2026-03-20-steinertree-to-ilp.md new file mode 100644 index 000000000..732faa074 --- /dev/null +++ b/docs/plans/2026-03-20-steinertree-to-ilp.md @@ -0,0 +1,454 @@ +# SteinerTree -> ILP Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement the direct `SteinerTree -> ILP` reduction from issue #123, add the canonical example/paper entry, and make the rule available through the reduction graph and `solve_reduced`. + +**Architecture:** Add a new `src/rules/steinertree_ilp.rs` module that builds a rooted multi-commodity-flow ILP: one binary selector `y_e` per source edge plus one binary directed-arc flow variable for each non-root terminal and each directed source edge. The source witness is exactly the first `m` target variables, so solution extraction is a prefix read. Keep the paper work in a separate batch after the rule, exports, and example witness are stable. + +**Tech Stack:** Rust, `good_lp` via `ilp-highs`, Typst, repo example-db exports, GitHub pipeline scripts + +--- + +## Batch 1: Rule, Tests, and Canonical Example + +### Task 1: Write the red tests and wire the module into the rule registry + +**Files:** +- Create: `src/unit_tests/rules/steinertree_ilp.rs` +- Modify: `src/rules/mod.rs` +- Reference: `src/rules/minimummultiwaycut_ilp.rs` +- Reference: `src/unit_tests/rules/minimummultiwaycut_ilp.rs` +- Reference: `src/models/graph/steiner_tree.rs` +- Reference: `src/models/algebraic/ilp.rs` + +**Step 1: Write the failing test file** + +Create `src/unit_tests/rules/steinertree_ilp.rs` with a canonical instance helper and these tests: + +```rust +use super::*; +use crate::models::algebraic::ObjectiveSense; +use crate::solvers::{BruteForce, ILPSolver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::SolutionSize; + +fn canonical_instance() -> SteinerTree { + let graph = SimpleGraph::new( + 5, + vec![(0, 1), (1, 2), (1, 3), (3, 4), (0, 3), (3, 2), (2, 4)], + ); + SteinerTree::new(graph, vec![2, 2, 1, 1, 5, 5, 6], vec![0, 2, 4]) +} + +#[test] +fn test_reduction_creates_expected_ilp_shape() { + let problem = canonical_instance(); + let reduction: ReductionSteinerTreeToILP = ReduceTo::>::reduce_to(&problem); + let ilp = reduction.target_problem(); + + assert_eq!(ilp.num_vars, 35); + assert_eq!(ilp.constraints.len(), 38); + assert_eq!(ilp.sense, ObjectiveSense::Minimize); + assert_eq!( + ilp.objective, + vec![(0, 2.0), (1, 2.0), (2, 1.0), (3, 1.0), (4, 5.0), (5, 5.0), (6, 6.0)], + ); +} + +#[test] +fn test_steinertree_to_ilp_closed_loop() { + let problem = canonical_instance(); + let reduction: ReductionSteinerTreeToILP = ReduceTo::>::reduce_to(&problem); + let ilp = reduction.target_problem(); + + let bf = BruteForce::new(); + let ilp_solver = ILPSolver::new(); + let best_source = bf.find_all_best(&problem); + let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); + let extracted = reduction.extract_solution(&ilp_solution); + + assert_eq!(problem.evaluate(&best_source[0]), SolutionSize::Valid(6)); + assert_eq!(problem.evaluate(&extracted), SolutionSize::Valid(6)); + assert!(problem.is_valid_solution(&extracted)); +} + +#[test] +fn test_solution_extraction_reads_edge_selector_prefix() { + let problem = canonical_instance(); + let reduction: ReductionSteinerTreeToILP = ReduceTo::>::reduce_to(&problem); + + let target_solution = vec![ + 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, 0, 0, 0, 0, 0, + ]; + + assert_eq!(reduction.extract_solution(&target_solution), vec![1, 1, 1, 1, 0, 0, 0]); +} + +#[test] +fn test_solve_reduced_uses_new_rule() { + let problem = canonical_instance(); + let solution = ILPSolver::new() + .solve_reduced(&problem) + .expect("solve_reduced should find the Steiner tree via ILP"); + assert_eq!(problem.evaluate(&solution), SolutionSize::Valid(6)); +} + +#[test] +#[should_panic(expected = "SteinerTree -> ILP requires nonnegative edge weights")] +fn test_reduction_rejects_negative_weights() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = SteinerTree::new(graph, vec![1, -2, 3], vec![0, 1]); + let _ = ReduceTo::>::reduce_to(&problem); +} +``` + +Do not use `assert_optimization_round_trip_from_optimization_target` here; the reduced ILP has 35 binary variables, so the target must be solved with `ILPSolver`, not brute force. + +**Step 2: Register the new module so the tests compile red** + +Modify `src/rules/mod.rs`: + +```rust +#[cfg(feature = "ilp-solver")] +pub(crate) mod steinertree_ilp; +``` + +And extend the example-spec collector inside the `#[cfg(feature = "ilp-solver")]` block: + +```rust +specs.extend(steinertree_ilp::canonical_rule_example_specs()); +``` + +**Step 3: Run the targeted test command and verify RED** + +Run: + +```bash +cargo test --features "ilp-highs example-db" steinertree_ilp -- --include-ignored +``` + +Expected: FAIL because `src/rules/steinertree_ilp.rs` and `ReductionSteinerTreeToILP` do not exist yet. + +**Step 4: Do not commit yet** + +Leave the branch in the red state and move directly to Task 2. + +### Task 2: Implement the reduction module and make the targeted tests green + +**Files:** +- Create: `src/rules/steinertree_ilp.rs` +- Modify: `src/rules/mod.rs` +- Test: `src/unit_tests/rules/steinertree_ilp.rs` + +**Step 1: Create the rule module with the same shape as other direct ILP reductions** + +Start from the `minimummultiwaycut_ilp.rs` pattern: + +```rust +use crate::models::algebraic::{ILP, LinearConstraint, ObjectiveSense}; +use crate::models::graph::SteinerTree; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::SimpleGraph; + +#[derive(Debug, Clone)] +pub struct ReductionSteinerTreeToILP { + target: ILP, + num_edges: usize, +} + +impl ReductionResult for ReductionSteinerTreeToILP { + type Source = SteinerTree; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution[..self.num_edges].to_vec() + } +} +``` + +**Step 2: Implement the rooted multi-commodity variable layout** + +Use this exact indexing scheme: + +```rust +let m = self.num_edges(); +let terminals = self.terminals(); +let non_root = &terminals[1..]; + +let edge_var = |edge_idx: usize| edge_idx; +let flow_var = |terminal_pos: usize, edge_idx: usize, dir: usize| { + m + terminal_pos * 2 * m + 2 * edge_idx + dir +}; +``` + +Interpretation: +- `dir = 0` means the edge orientation as stored in `graph.edges()[edge_idx]`, `u -> v` +- `dir = 1` means the reverse orientation, `v -> u` +- total variables: `m + 2 * m * (k - 1)` + +**Step 3: Implement the ILP constraints** + +Use the issue's exact formulation: + +```rust +#[reduction( + overhead = { + num_vars = "num_edges + 2 * num_edges * (num_terminals - 1)", + num_constraints = "num_vertices * (num_terminals - 1) + 2 * num_edges * (num_terminals - 1)", + } +)] +impl ReduceTo> for SteinerTree { + type Result = ReductionSteinerTreeToILP; + + fn reduce_to(&self) -> Self::Result { + assert!( + self.edge_weights().iter().all(|&w| w >= 0), + "SteinerTree -> ILP requires nonnegative edge weights", + ); + + // build: + // 1. one flow-conservation equality per (terminal, vertex) + // 2. two capacity-linking inequalities per (terminal, edge) + // 3. objective on y_e only + } +} +``` + +For each non-root terminal `t` and each vertex `v`, add one equality: +- root `r = terminals[0]`: incoming minus outgoing `= -1` +- sink `v = t`: incoming minus outgoing `= 1` +- otherwise `= 0` + +For each non-root terminal and each undirected edge `e = (u, v)`: +- `f_t(u,v) <= y_e` +- `f_t(v,u) <= y_e` + +Keep the target domain binary (`ILP`). Even though the issue writes `f^t_(u,v) in [0,1]`, a binary flow variable is valid here because each commodity can be routed on a simple root-to-terminal path inside an optimal tree, and the codebase does not expose a continuous LP model. + +**Step 4: Implement the canonical example in the rule module** + +Add `#[cfg(feature = "example-db")]` `canonical_rule_example_specs()` in the new rule file. Reuse the exact issue-123 instance: + +```rust +let source = SteinerTree::new( + SimpleGraph::new( + 5, + vec![(0, 1), (1, 2), (1, 3), (3, 4), (0, 3), (3, 2), (2, 4)], + ), + vec![2, 2, 1, 1, 5, 5, 6], + vec![0, 2, 4], +); +``` + +Use this canonical witness pair: + +```rust +SolutionPair { + source_config: vec![1, 1, 1, 1, 0, 0, 0], + target_config: vec![ + 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, 0, 0, 0, 0, 0, + ], +} +``` + +That target witness corresponds to: +- root `0` +- commodity `2`: path `0 -> 1 -> 2` +- commodity `4`: path `0 -> 1 -> 3 -> 4` + +**Step 5: Run the targeted tests and verify GREEN** + +Run: + +```bash +cargo test --features "ilp-highs example-db" steinertree_ilp -- --include-ignored +``` + +Expected: PASS for all `steinertree_ilp` tests. + +**Step 6: Commit the green implementation** + +Run: + +```bash +git add src/rules/mod.rs src/rules/steinertree_ilp.rs src/unit_tests/rules/steinertree_ilp.rs +git commit -m "Add SteinerTree to ILP reduction" +``` + +### Task 3: Run rule-level exports and verify the example path before touching the paper + +**Files:** +- Modify: `src/rules/steinertree_ilp.rs` if export/example issues appear +- Reference: `src/rules/mod.rs` + +**Step 1: Regenerate the example export and graph metadata** + +Run: + +```bash +cargo run --features "example-db" --example export_examples +cargo run --example export_graph +cargo run --example export_schemas +``` + +Expected: +- `export_examples` includes the new `SteinerTree -> ILP` canonical rule entry +- `export_graph` sees the new edge +- `export_schemas` remains clean + +**Step 2: Re-run the targeted rule tests after exports** + +Run: + +```bash +cargo test --features "ilp-highs example-db" steinertree_ilp -- --include-ignored +``` + +Expected: PASS again. + +**Step 3: If the export/example step exposed mismatches, fix them before Batch 2** + +Typical fixes: +- wrong `target_config` ordering in the example witness +- missing `canonical_rule_example_specs()` registration in `src/rules/mod.rs` +- overhead mismatch caused by the wrong variable count formula + +Do not start the paper until the exported example data is stable. + +## Batch 2: Paper Entry and Final Verification + +### Task 4: Add the Typst paper entry and bibliography + +**Files:** +- Modify: `docs/paper/reductions.typ` +- Modify: `docs/paper/references.bib` +- Reference: `docs/paper/reductions.typ` (`KColoring -> QUBO`, `MinimumMultiwayCut -> ILP`) + +**Step 1: Add the missing bibliography entries with corrected DOIs** + +Add entries for: +- Wong, 1984, `10.1007/BF02612335` +- Koch and Martin, 1998, `10.1002/(SICI)1097-0037(199810)32:3<207::AID-NET5>3.0.CO;2-O` + +Use stable keys that match the rest of the bibliography, for example: +- `wong1984steiner` +- `kochmartin1998steiner` + +**Step 2: Load the canonical example data near the ILP section** + +Add a local binding before the new theorem: + +```typst +#let st_ilp = load-example("SteinerTree", "ILP") +#let st_ilp_sol = st_ilp.solutions.at(0) +``` + +**Step 3: Write the new `reduction-rule("SteinerTree", "ILP", ...)` entry** + +Place it near the other network-flow ILP formulations, preferably adjacent to `MinimumMultiwayCut -> ILP`. + +The theorem body should state: +- this is the standard multi-commodity flow ILP formulation +- target size is `m + 2m(k - 1)` variables and `n(k - 1) + 2m(k - 1)` constraints +- citations point to the corrected Wong/Koch-Martin references + +The proof body should include: +- `_Construction._` root choice, `y_e`, arc-flow variables, conservation/linking constraints, minimize edge weights +- `_Correctness._` why any Steiner tree induces feasible flows, and why any optimal feasible solution gives a minimum-cost connected subgraph whose selected edges can be read as a Steiner tree under the nonnegative-weight assumption +- `_Solution extraction._` read the first `m` variables as edge selectors + +**Step 4: Add a worked example block driven by JSON data** + +Set `example: true` and use the issue's 5-vertex, 7-edge, 3-terminal instance. The extra block should: +- show the source graph, terminals, and root `0` +- explain `7 + 2 * 7 * 2 = 35` variables and `5 * 2 + 2 * 7 * 2 = 38` constraints +- walk through the two commodity paths encoded by `st_ilp_sol.target_config` +- verify that the selected edges are `{(0,1), (1,2), (1,3), (3,4)}` with total cost `6` +- state that the fixture stores one canonical witness + +Do not hardcode the final counts or objective; pull them from `st_ilp.source.instance`, `st_ilp.target.instance`, and `st_ilp_sol`. + +**Step 5: Build the paper and verify GREEN** + +Run: + +```bash +make paper +``` + +Expected: PASS with no new completeness warnings for missing `SteinerTree -> ILP` coverage. + +**Step 6: Commit the paper/docs batch** + +Run: + +```bash +git add docs/paper/reductions.typ docs/paper/references.bib +git commit -m "Document SteinerTree to ILP reduction" +``` + +### Task 5: Final verification for the full issue branch + +**Files:** +- Verify: `src/rules/steinertree_ilp.rs` +- Verify: `src/unit_tests/rules/steinertree_ilp.rs` +- Verify: `src/rules/mod.rs` +- Verify: `docs/paper/reductions.typ` +- Verify: `docs/paper/references.bib` + +**Step 1: Run the focused regression checks** + +Run: + +```bash +cargo test --features "ilp-highs example-db" steinertree_ilp -- --include-ignored +``` + +Expected: PASS. + +**Step 2: Run the repo-wide required checks** + +Run: + +```bash +make clippy +make test +``` + +Expected: +- `make clippy` passes with `-D warnings` +- `make test` passes with `--features "ilp-highs example-db" -- --include-ignored` + +**Step 3: Confirm the working tree only has intended tracked changes** + +Run: + +```bash +git status --short +``` + +Expected: +- tracked changes only in the rule/test/docs files above +- generated paper/example outputs remain ignored + +**Step 4: Commit any final fixups** + +If verification forced additional source changes, run: + +```bash +git add -A +git commit -m "Polish SteinerTree to ILP implementation" +``` + +If verification is clean, do not add an extra commit. From b6b32eca2520cb7612fb37b7b9c43f0ca9046658 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 14:55:12 +0800 Subject: [PATCH 2/9] Add SteinerTree to ILP reduction --- src/rules/mod.rs | 3 + src/rules/steinertree_ilp.rs | 156 ++++++++++++++++++++++++ src/unit_tests/rules/steinertree_ilp.rs | 89 ++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 src/rules/steinertree_ilp.rs create mode 100644 src/unit_tests/rules/steinertree_ilp.rs diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 77f3d8941..8a3b54367 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -75,6 +75,8 @@ pub(crate) mod minimumsetcovering_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod qubo_ilp; #[cfg(feature = "ilp-solver")] +pub(crate) mod steinertree_ilp; +#[cfg(feature = "ilp-solver")] pub(crate) mod travelingsalesman_ilp; pub use graph::{ @@ -125,6 +127,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + num_edges: usize, +} + +impl ReductionResult for ReductionSteinerTreeToILP { + type Source = SteinerTree; + type Target = ILP; + + fn target_problem(&self) -> &ILP { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution[..self.num_edges].to_vec() + } +} + +#[reduction( + overhead = { + num_vars = "num_edges + 2 * num_edges * (num_terminals - 1)", + num_constraints = "num_vertices * (num_terminals - 1) + 2 * num_edges * (num_terminals - 1)", + } +)] +impl ReduceTo> for SteinerTree { + type Result = ReductionSteinerTreeToILP; + + fn reduce_to(&self) -> Self::Result { + assert!( + self.edge_weights().iter().all(|&weight| weight >= 0), + "SteinerTree -> ILP requires nonnegative edge weights" + ); + + let n = self.num_vertices(); + let m = self.num_edges(); + let root = self.terminals()[0]; + let non_root_terminals = &self.terminals()[1..]; + let edges = self.graph().edges(); + let num_vars = m + 2 * m * non_root_terminals.len(); + let num_constraints = n * non_root_terminals.len() + 2 * m * non_root_terminals.len(); + let mut constraints = Vec::with_capacity(num_constraints); + + let edge_var = |edge_idx: usize| edge_idx; + let flow_var = |terminal_pos: usize, edge_idx: usize, dir: usize| -> usize { + m + terminal_pos * 2 * m + 2 * edge_idx + dir + }; + + for (terminal_pos, &terminal) in non_root_terminals.iter().enumerate() { + for vertex in 0..n { + let mut terms = Vec::new(); + for (edge_idx, &(u, v)) in edges.iter().enumerate() { + if v == vertex { + terms.push((flow_var(terminal_pos, edge_idx, 0), 1.0)); + terms.push((flow_var(terminal_pos, edge_idx, 1), -1.0)); + } + if u == vertex { + terms.push((flow_var(terminal_pos, edge_idx, 0), -1.0)); + terms.push((flow_var(terminal_pos, edge_idx, 1), 1.0)); + } + } + + let rhs = if vertex == root { + -1.0 + } else if vertex == terminal { + 1.0 + } else { + 0.0 + }; + constraints.push(LinearConstraint::eq(terms, rhs)); + } + } + + for terminal_pos in 0..non_root_terminals.len() { + for edge_idx in 0..m { + let selector = edge_var(edge_idx); + constraints.push(LinearConstraint::le( + vec![(flow_var(terminal_pos, edge_idx, 0), 1.0), (selector, -1.0)], + 0.0, + )); + constraints.push(LinearConstraint::le( + vec![(flow_var(terminal_pos, edge_idx, 1), 1.0), (selector, -1.0)], + 0.0, + )); + } + } + + let objective: Vec<(usize, f64)> = self + .edge_weights() + .iter() + .enumerate() + .map(|(edge_idx, &weight)| (edge_var(edge_idx), weight as f64)) + .collect(); + + let target = ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize); + + ReductionSteinerTreeToILP { + target, + num_edges: m, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "steinertree_to_ilp", + build: || { + let source = SteinerTree::new( + SimpleGraph::new( + 5, + vec![(0, 1), (1, 2), (1, 3), (3, 4), (0, 3), (3, 2), (2, 4)], + ), + vec![2, 2, 1, 1, 5, 5, 6], + vec![0, 2, 4], + ); + crate::example_db::specs::rule_example_with_witness::<_, ILP>( + source, + SolutionPair { + source_config: vec![1, 1, 1, 1, 0, 0, 0], + target_config: vec![ + 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, 0, 0, 0, 0, 0, + ], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/steinertree_ilp.rs"] +mod tests; diff --git a/src/unit_tests/rules/steinertree_ilp.rs b/src/unit_tests/rules/steinertree_ilp.rs new file mode 100644 index 000000000..52f81a0db --- /dev/null +++ b/src/unit_tests/rules/steinertree_ilp.rs @@ -0,0 +1,89 @@ +use super::*; +use crate::models::algebraic::{ILP, ObjectiveSense}; +use crate::models::graph::SteinerTree; +use crate::rules::ReduceTo; +use crate::solvers::{BruteForce, ILPSolver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::SolutionSize; + +fn canonical_instance() -> SteinerTree { + let graph = SimpleGraph::new( + 5, + vec![(0, 1), (1, 2), (1, 3), (3, 4), (0, 3), (3, 2), (2, 4)], + ); + SteinerTree::new(graph, vec![2, 2, 1, 1, 5, 5, 6], vec![0, 2, 4]) +} + +#[test] +fn test_reduction_creates_expected_ilp_shape() { + let problem = canonical_instance(); + let reduction: ReductionSteinerTreeToILP = ReduceTo::>::reduce_to(&problem); + let ilp = reduction.target_problem(); + + assert_eq!(ilp.num_vars, 35); + assert_eq!(ilp.constraints.len(), 38); + assert_eq!(ilp.sense, ObjectiveSense::Minimize); + assert_eq!( + ilp.objective, + vec![ + (0, 2.0), + (1, 2.0), + (2, 1.0), + (3, 1.0), + (4, 5.0), + (5, 5.0), + (6, 6.0), + ] + ); +} + +#[test] +fn test_steinertree_to_ilp_closed_loop() { + let problem = canonical_instance(); + let reduction: ReductionSteinerTreeToILP = ReduceTo::>::reduce_to(&problem); + let ilp = reduction.target_problem(); + + let bf = BruteForce::new(); + let ilp_solver = ILPSolver::new(); + let best_source = bf.find_all_best(&problem); + let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); + let extracted = reduction.extract_solution(&ilp_solution); + + assert_eq!(problem.evaluate(&best_source[0]), SolutionSize::Valid(6)); + assert_eq!(problem.evaluate(&extracted), SolutionSize::Valid(6)); + assert!(problem.is_valid_solution(&extracted)); +} + +#[test] +fn test_solution_extraction_reads_edge_selector_prefix() { + let problem = canonical_instance(); + let reduction: ReductionSteinerTreeToILP = ReduceTo::>::reduce_to(&problem); + + let target_solution = vec![ + 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, 0, 0, 0, 0, 0, + ]; + + assert_eq!( + reduction.extract_solution(&target_solution), + vec![1, 1, 1, 1, 0, 0, 0] + ); +} + +#[test] +fn test_solve_reduced_uses_new_rule() { + let problem = canonical_instance(); + let solution = ILPSolver::new() + .solve_reduced(&problem) + .expect("solve_reduced should find the Steiner tree via ILP"); + assert_eq!(problem.evaluate(&solution), SolutionSize::Valid(6)); +} + +#[test] +#[should_panic(expected = "SteinerTree -> ILP requires nonnegative edge weights")] +fn test_reduction_rejects_negative_weights() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = SteinerTree::new(graph, vec![1, -2, 3], vec![0, 1]); + let _ = ReduceTo::>::reduce_to(&problem); +} From 808cbc794c0a20721f53d543adc35607f001f7cd Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 15:01:04 +0800 Subject: [PATCH 3/9] Document SteinerTree to ILP reduction --- docs/paper/reductions.typ | 45 +++++++++++++++++++++++++++++++++++++++ docs/paper/references.bib | 22 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index cd1a3525b..4da976fc5 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -4826,6 +4826,51 @@ The following reductions to Integer Linear Programming are straightforward formu _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). ] +#let st_ilp = load-example("SteinerTree", "ILP") +#let st_ilp_sol = st_ilp.solutions.at(0) +#let st_edges = st_ilp.source.instance.graph.edges +#let st_weights = st_ilp.source.instance.edge_weights +#let st_terminals = st_ilp.source.instance.terminals +#let st_root = st_terminals.at(0) +#let st_non_root_terminals = range(1, st_terminals.len()).map(i => st_terminals.at(i)) +#let st_selected_edge_indices = st_ilp_sol.source_config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) +#let st_selected_edges = st_selected_edge_indices.map(i => st_edges.at(i)) +#let st_cost = st_selected_edge_indices.map(i => st_weights.at(i)).sum() + +#reduction-rule("SteinerTree", "ILP", + example: true, + example-caption: [Canonical Steiner tree instance ($n = #st_ilp.source.instance.graph.num_vertices$, $m = #st_edges.len()$, $|T| = #st_terminals.len()$)], + extra: [ + *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)$. + + *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())$. + + *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. + + *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 + + *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. + + *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. + ], +)[ + 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. +][ + _Construction._ Given an undirected weighted graph $G = (V, E, w)$ with nonnegative 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 + $ min sum_(e in E) w_e y_e. $ + For every commodity $t$ and vertex $v$, enforce flow conservation: + $ sum_(u : (u, v) in A) f^t_(u,v) - sum_(u : (v, u) in A) f^t_(v,u) = b_(t,v), $ + 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 + $ f^t_(u,v) <= y_e quad "and" quad f^t_(v,u) <= y_e. $ + 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. + + _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 nonnegative, deleting any cycle edge from that connected subgraph cannot increase the objective; pruning repeatedly yields a Steiner tree with cost at most the ILP objective. Therefore an optimal ILP solution induces a minimum-cost Steiner tree. + + _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)$. + + _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. +] + == Unit Disk Mapping #reduction-rule("MaximumIndependentSet", "KingsSubgraph")[ diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 3d145bc7f..654e769df 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -999,6 +999,28 @@ @article{chopra1996 doi = {10.1007/BF02592096} } +@article{wong1984steiner, + author = {R. T. Wong}, + title = {A Dual Ascent Approach for Steiner Tree Problems on a Directed Graph}, + journal = {Mathematical Programming}, + volume = {28}, + number = {3}, + pages = {271--287}, + year = {1984}, + doi = {10.1007/BF02612335} +} + +@article{kochmartin1998steiner, + author = {Thorsten Koch and Alexander Martin}, + title = {Solving Steiner Tree Problems in Graphs to Optimality}, + journal = {Networks}, + volume = {32}, + number = {3}, + pages = {207--232}, + year = {1998}, + doi = {10.1002/(SICI)1097-0037(199810)32:3<207::AID-NET5>3.0.CO;2-O} +} + @article{kou1977, author = {Lawrence T. Kou}, title = {Polynomial Complete Consecutive Information Retrieval Problems}, From a53445e7ad9d286905dde3f0121822418964c2e9 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 20 Mar 2026 15:04:42 +0800 Subject: [PATCH 4/9] chore: remove plan file after implementation --- docs/plans/2026-03-20-steinertree-to-ilp.md | 454 -------------------- 1 file changed, 454 deletions(-) delete mode 100644 docs/plans/2026-03-20-steinertree-to-ilp.md diff --git a/docs/plans/2026-03-20-steinertree-to-ilp.md b/docs/plans/2026-03-20-steinertree-to-ilp.md deleted file mode 100644 index 732faa074..000000000 --- a/docs/plans/2026-03-20-steinertree-to-ilp.md +++ /dev/null @@ -1,454 +0,0 @@ -# SteinerTree -> ILP Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement the direct `SteinerTree -> ILP` reduction from issue #123, add the canonical example/paper entry, and make the rule available through the reduction graph and `solve_reduced`. - -**Architecture:** Add a new `src/rules/steinertree_ilp.rs` module that builds a rooted multi-commodity-flow ILP: one binary selector `y_e` per source edge plus one binary directed-arc flow variable for each non-root terminal and each directed source edge. The source witness is exactly the first `m` target variables, so solution extraction is a prefix read. Keep the paper work in a separate batch after the rule, exports, and example witness are stable. - -**Tech Stack:** Rust, `good_lp` via `ilp-highs`, Typst, repo example-db exports, GitHub pipeline scripts - ---- - -## Batch 1: Rule, Tests, and Canonical Example - -### Task 1: Write the red tests and wire the module into the rule registry - -**Files:** -- Create: `src/unit_tests/rules/steinertree_ilp.rs` -- Modify: `src/rules/mod.rs` -- Reference: `src/rules/minimummultiwaycut_ilp.rs` -- Reference: `src/unit_tests/rules/minimummultiwaycut_ilp.rs` -- Reference: `src/models/graph/steiner_tree.rs` -- Reference: `src/models/algebraic/ilp.rs` - -**Step 1: Write the failing test file** - -Create `src/unit_tests/rules/steinertree_ilp.rs` with a canonical instance helper and these tests: - -```rust -use super::*; -use crate::models::algebraic::ObjectiveSense; -use crate::solvers::{BruteForce, ILPSolver}; -use crate::topology::SimpleGraph; -use crate::traits::Problem; -use crate::types::SolutionSize; - -fn canonical_instance() -> SteinerTree { - let graph = SimpleGraph::new( - 5, - vec![(0, 1), (1, 2), (1, 3), (3, 4), (0, 3), (3, 2), (2, 4)], - ); - SteinerTree::new(graph, vec![2, 2, 1, 1, 5, 5, 6], vec![0, 2, 4]) -} - -#[test] -fn test_reduction_creates_expected_ilp_shape() { - let problem = canonical_instance(); - let reduction: ReductionSteinerTreeToILP = ReduceTo::>::reduce_to(&problem); - let ilp = reduction.target_problem(); - - assert_eq!(ilp.num_vars, 35); - assert_eq!(ilp.constraints.len(), 38); - assert_eq!(ilp.sense, ObjectiveSense::Minimize); - assert_eq!( - ilp.objective, - vec![(0, 2.0), (1, 2.0), (2, 1.0), (3, 1.0), (4, 5.0), (5, 5.0), (6, 6.0)], - ); -} - -#[test] -fn test_steinertree_to_ilp_closed_loop() { - let problem = canonical_instance(); - let reduction: ReductionSteinerTreeToILP = ReduceTo::>::reduce_to(&problem); - let ilp = reduction.target_problem(); - - let bf = BruteForce::new(); - let ilp_solver = ILPSolver::new(); - let best_source = bf.find_all_best(&problem); - let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); - - assert_eq!(problem.evaluate(&best_source[0]), SolutionSize::Valid(6)); - assert_eq!(problem.evaluate(&extracted), SolutionSize::Valid(6)); - assert!(problem.is_valid_solution(&extracted)); -} - -#[test] -fn test_solution_extraction_reads_edge_selector_prefix() { - let problem = canonical_instance(); - let reduction: ReductionSteinerTreeToILP = ReduceTo::>::reduce_to(&problem); - - let target_solution = vec![ - 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, 0, 0, 0, 0, 0, - ]; - - assert_eq!(reduction.extract_solution(&target_solution), vec![1, 1, 1, 1, 0, 0, 0]); -} - -#[test] -fn test_solve_reduced_uses_new_rule() { - let problem = canonical_instance(); - let solution = ILPSolver::new() - .solve_reduced(&problem) - .expect("solve_reduced should find the Steiner tree via ILP"); - assert_eq!(problem.evaluate(&solution), SolutionSize::Valid(6)); -} - -#[test] -#[should_panic(expected = "SteinerTree -> ILP requires nonnegative edge weights")] -fn test_reduction_rejects_negative_weights() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); - let problem = SteinerTree::new(graph, vec![1, -2, 3], vec![0, 1]); - let _ = ReduceTo::>::reduce_to(&problem); -} -``` - -Do not use `assert_optimization_round_trip_from_optimization_target` here; the reduced ILP has 35 binary variables, so the target must be solved with `ILPSolver`, not brute force. - -**Step 2: Register the new module so the tests compile red** - -Modify `src/rules/mod.rs`: - -```rust -#[cfg(feature = "ilp-solver")] -pub(crate) mod steinertree_ilp; -``` - -And extend the example-spec collector inside the `#[cfg(feature = "ilp-solver")]` block: - -```rust -specs.extend(steinertree_ilp::canonical_rule_example_specs()); -``` - -**Step 3: Run the targeted test command and verify RED** - -Run: - -```bash -cargo test --features "ilp-highs example-db" steinertree_ilp -- --include-ignored -``` - -Expected: FAIL because `src/rules/steinertree_ilp.rs` and `ReductionSteinerTreeToILP` do not exist yet. - -**Step 4: Do not commit yet** - -Leave the branch in the red state and move directly to Task 2. - -### Task 2: Implement the reduction module and make the targeted tests green - -**Files:** -- Create: `src/rules/steinertree_ilp.rs` -- Modify: `src/rules/mod.rs` -- Test: `src/unit_tests/rules/steinertree_ilp.rs` - -**Step 1: Create the rule module with the same shape as other direct ILP reductions** - -Start from the `minimummultiwaycut_ilp.rs` pattern: - -```rust -use crate::models::algebraic::{ILP, LinearConstraint, ObjectiveSense}; -use crate::models::graph::SteinerTree; -use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; -use crate::topology::SimpleGraph; - -#[derive(Debug, Clone)] -pub struct ReductionSteinerTreeToILP { - target: ILP, - num_edges: usize, -} - -impl ReductionResult for ReductionSteinerTreeToILP { - type Source = SteinerTree; - type Target = ILP; - - fn target_problem(&self) -> &ILP { - &self.target - } - - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_edges].to_vec() - } -} -``` - -**Step 2: Implement the rooted multi-commodity variable layout** - -Use this exact indexing scheme: - -```rust -let m = self.num_edges(); -let terminals = self.terminals(); -let non_root = &terminals[1..]; - -let edge_var = |edge_idx: usize| edge_idx; -let flow_var = |terminal_pos: usize, edge_idx: usize, dir: usize| { - m + terminal_pos * 2 * m + 2 * edge_idx + dir -}; -``` - -Interpretation: -- `dir = 0` means the edge orientation as stored in `graph.edges()[edge_idx]`, `u -> v` -- `dir = 1` means the reverse orientation, `v -> u` -- total variables: `m + 2 * m * (k - 1)` - -**Step 3: Implement the ILP constraints** - -Use the issue's exact formulation: - -```rust -#[reduction( - overhead = { - num_vars = "num_edges + 2 * num_edges * (num_terminals - 1)", - num_constraints = "num_vertices * (num_terminals - 1) + 2 * num_edges * (num_terminals - 1)", - } -)] -impl ReduceTo> for SteinerTree { - type Result = ReductionSteinerTreeToILP; - - fn reduce_to(&self) -> Self::Result { - assert!( - self.edge_weights().iter().all(|&w| w >= 0), - "SteinerTree -> ILP requires nonnegative edge weights", - ); - - // build: - // 1. one flow-conservation equality per (terminal, vertex) - // 2. two capacity-linking inequalities per (terminal, edge) - // 3. objective on y_e only - } -} -``` - -For each non-root terminal `t` and each vertex `v`, add one equality: -- root `r = terminals[0]`: incoming minus outgoing `= -1` -- sink `v = t`: incoming minus outgoing `= 1` -- otherwise `= 0` - -For each non-root terminal and each undirected edge `e = (u, v)`: -- `f_t(u,v) <= y_e` -- `f_t(v,u) <= y_e` - -Keep the target domain binary (`ILP`). Even though the issue writes `f^t_(u,v) in [0,1]`, a binary flow variable is valid here because each commodity can be routed on a simple root-to-terminal path inside an optimal tree, and the codebase does not expose a continuous LP model. - -**Step 4: Implement the canonical example in the rule module** - -Add `#[cfg(feature = "example-db")]` `canonical_rule_example_specs()` in the new rule file. Reuse the exact issue-123 instance: - -```rust -let source = SteinerTree::new( - SimpleGraph::new( - 5, - vec![(0, 1), (1, 2), (1, 3), (3, 4), (0, 3), (3, 2), (2, 4)], - ), - vec![2, 2, 1, 1, 5, 5, 6], - vec![0, 2, 4], -); -``` - -Use this canonical witness pair: - -```rust -SolutionPair { - source_config: vec![1, 1, 1, 1, 0, 0, 0], - target_config: vec![ - 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, 0, 0, 0, 0, 0, - ], -} -``` - -That target witness corresponds to: -- root `0` -- commodity `2`: path `0 -> 1 -> 2` -- commodity `4`: path `0 -> 1 -> 3 -> 4` - -**Step 5: Run the targeted tests and verify GREEN** - -Run: - -```bash -cargo test --features "ilp-highs example-db" steinertree_ilp -- --include-ignored -``` - -Expected: PASS for all `steinertree_ilp` tests. - -**Step 6: Commit the green implementation** - -Run: - -```bash -git add src/rules/mod.rs src/rules/steinertree_ilp.rs src/unit_tests/rules/steinertree_ilp.rs -git commit -m "Add SteinerTree to ILP reduction" -``` - -### Task 3: Run rule-level exports and verify the example path before touching the paper - -**Files:** -- Modify: `src/rules/steinertree_ilp.rs` if export/example issues appear -- Reference: `src/rules/mod.rs` - -**Step 1: Regenerate the example export and graph metadata** - -Run: - -```bash -cargo run --features "example-db" --example export_examples -cargo run --example export_graph -cargo run --example export_schemas -``` - -Expected: -- `export_examples` includes the new `SteinerTree -> ILP` canonical rule entry -- `export_graph` sees the new edge -- `export_schemas` remains clean - -**Step 2: Re-run the targeted rule tests after exports** - -Run: - -```bash -cargo test --features "ilp-highs example-db" steinertree_ilp -- --include-ignored -``` - -Expected: PASS again. - -**Step 3: If the export/example step exposed mismatches, fix them before Batch 2** - -Typical fixes: -- wrong `target_config` ordering in the example witness -- missing `canonical_rule_example_specs()` registration in `src/rules/mod.rs` -- overhead mismatch caused by the wrong variable count formula - -Do not start the paper until the exported example data is stable. - -## Batch 2: Paper Entry and Final Verification - -### Task 4: Add the Typst paper entry and bibliography - -**Files:** -- Modify: `docs/paper/reductions.typ` -- Modify: `docs/paper/references.bib` -- Reference: `docs/paper/reductions.typ` (`KColoring -> QUBO`, `MinimumMultiwayCut -> ILP`) - -**Step 1: Add the missing bibliography entries with corrected DOIs** - -Add entries for: -- Wong, 1984, `10.1007/BF02612335` -- Koch and Martin, 1998, `10.1002/(SICI)1097-0037(199810)32:3<207::AID-NET5>3.0.CO;2-O` - -Use stable keys that match the rest of the bibliography, for example: -- `wong1984steiner` -- `kochmartin1998steiner` - -**Step 2: Load the canonical example data near the ILP section** - -Add a local binding before the new theorem: - -```typst -#let st_ilp = load-example("SteinerTree", "ILP") -#let st_ilp_sol = st_ilp.solutions.at(0) -``` - -**Step 3: Write the new `reduction-rule("SteinerTree", "ILP", ...)` entry** - -Place it near the other network-flow ILP formulations, preferably adjacent to `MinimumMultiwayCut -> ILP`. - -The theorem body should state: -- this is the standard multi-commodity flow ILP formulation -- target size is `m + 2m(k - 1)` variables and `n(k - 1) + 2m(k - 1)` constraints -- citations point to the corrected Wong/Koch-Martin references - -The proof body should include: -- `_Construction._` root choice, `y_e`, arc-flow variables, conservation/linking constraints, minimize edge weights -- `_Correctness._` why any Steiner tree induces feasible flows, and why any optimal feasible solution gives a minimum-cost connected subgraph whose selected edges can be read as a Steiner tree under the nonnegative-weight assumption -- `_Solution extraction._` read the first `m` variables as edge selectors - -**Step 4: Add a worked example block driven by JSON data** - -Set `example: true` and use the issue's 5-vertex, 7-edge, 3-terminal instance. The extra block should: -- show the source graph, terminals, and root `0` -- explain `7 + 2 * 7 * 2 = 35` variables and `5 * 2 + 2 * 7 * 2 = 38` constraints -- walk through the two commodity paths encoded by `st_ilp_sol.target_config` -- verify that the selected edges are `{(0,1), (1,2), (1,3), (3,4)}` with total cost `6` -- state that the fixture stores one canonical witness - -Do not hardcode the final counts or objective; pull them from `st_ilp.source.instance`, `st_ilp.target.instance`, and `st_ilp_sol`. - -**Step 5: Build the paper and verify GREEN** - -Run: - -```bash -make paper -``` - -Expected: PASS with no new completeness warnings for missing `SteinerTree -> ILP` coverage. - -**Step 6: Commit the paper/docs batch** - -Run: - -```bash -git add docs/paper/reductions.typ docs/paper/references.bib -git commit -m "Document SteinerTree to ILP reduction" -``` - -### Task 5: Final verification for the full issue branch - -**Files:** -- Verify: `src/rules/steinertree_ilp.rs` -- Verify: `src/unit_tests/rules/steinertree_ilp.rs` -- Verify: `src/rules/mod.rs` -- Verify: `docs/paper/reductions.typ` -- Verify: `docs/paper/references.bib` - -**Step 1: Run the focused regression checks** - -Run: - -```bash -cargo test --features "ilp-highs example-db" steinertree_ilp -- --include-ignored -``` - -Expected: PASS. - -**Step 2: Run the repo-wide required checks** - -Run: - -```bash -make clippy -make test -``` - -Expected: -- `make clippy` passes with `-D warnings` -- `make test` passes with `--features "ilp-highs example-db" -- --include-ignored` - -**Step 3: Confirm the working tree only has intended tracked changes** - -Run: - -```bash -git status --short -``` - -Expected: -- tracked changes only in the rule/test/docs files above -- generated paper/example outputs remain ignored - -**Step 4: Commit any final fixups** - -If verification forced additional source changes, run: - -```bash -git add -A -git commit -m "Polish SteinerTree to ILP implementation" -``` - -If verification is clean, do not add an extra commit. From ae944e2fc44d9c2c7d6840dd6370fce85dc7eb91 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 21 Mar 2026 02:59:18 +0800 Subject: [PATCH 5/9] Tighten SteinerTree->ILP guard to strictly positive weights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/rules/steinertree_ilp.rs | 4 ++-- src/unit_tests/rules/steinertree_ilp.rs | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/rules/steinertree_ilp.rs b/src/rules/steinertree_ilp.rs index aa41b7f00..be79f2498 100644 --- a/src/rules/steinertree_ilp.rs +++ b/src/rules/steinertree_ilp.rs @@ -49,8 +49,8 @@ impl ReduceTo> for SteinerTree { fn reduce_to(&self) -> Self::Result { assert!( - self.edge_weights().iter().all(|&weight| weight >= 0), - "SteinerTree -> ILP requires nonnegative edge weights" + self.edge_weights().iter().all(|&weight| weight > 0), + "SteinerTree -> ILP requires strictly positive edge weights (zero-weight edges should be contracted beforehand)" ); let n = self.num_vertices(); diff --git a/src/unit_tests/rules/steinertree_ilp.rs b/src/unit_tests/rules/steinertree_ilp.rs index 52f81a0db..35ec9e54a 100644 --- a/src/unit_tests/rules/steinertree_ilp.rs +++ b/src/unit_tests/rules/steinertree_ilp.rs @@ -81,9 +81,17 @@ fn test_solve_reduced_uses_new_rule() { } #[test] -#[should_panic(expected = "SteinerTree -> ILP requires nonnegative edge weights")] +#[should_panic(expected = "SteinerTree -> ILP requires strictly positive edge weights")] fn test_reduction_rejects_negative_weights() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); let problem = SteinerTree::new(graph, vec![1, -2, 3], vec![0, 1]); let _ = ReduceTo::>::reduce_to(&problem); } + +#[test] +#[should_panic(expected = "SteinerTree -> ILP requires strictly positive edge weights")] +fn test_reduction_rejects_zero_weights() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = SteinerTree::new(graph, vec![0, 0, 0], vec![0, 1]); + let _ = ReduceTo::>::reduce_to(&problem); +} From 48f9a227d6aded11cda10457406b34554f5a7860 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 21 Mar 2026 03:00:16 +0800 Subject: [PATCH 6/9] 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) --- docs/paper/reductions.typ | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 82f0d9d4d..719b92c19 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -5360,7 +5360,7 @@ The following reductions to Integer Linear Programming are straightforward formu )[ 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. ][ - _Construction._ Given an undirected weighted graph $G = (V, E, w)$ with nonnegative 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 + _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 $ min sum_(e in E) w_e y_e. $ For every commodity $t$ and vertex $v$, enforce flow conservation: $ sum_(u : (u, v) in A) f^t_(u,v) - sum_(u : (v, u) in A) f^t_(v,u) = b_(t,v), $ @@ -5368,7 +5368,7 @@ The following reductions to Integer Linear Programming are straightforward formu $ f^t_(u,v) <= y_e quad "and" quad f^t_(v,u) <= y_e. $ 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. - _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 nonnegative, deleting any cycle edge from that connected subgraph cannot increase the objective; pruning repeatedly yields a Steiner tree with cost at most the ILP objective. Therefore an optimal ILP solution induces a minimum-cost Steiner tree. + _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. _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)$. From d2dcdd3d9ead25c85cbbc5902d4282e69b5688d6 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 21 Mar 2026 03:00:48 +0800 Subject: [PATCH 7/9] cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) --- src/rules/steinertree_ilp.rs | 6 +++--- src/unit_tests/rules/steinertree_ilp.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/rules/steinertree_ilp.rs b/src/rules/steinertree_ilp.rs index be79f2498..fd403d33d 100644 --- a/src/rules/steinertree_ilp.rs +++ b/src/rules/steinertree_ilp.rs @@ -7,7 +7,7 @@ //! `f^t_(u,v) <= y_e` //! - Objective: minimize the total weight of selected edges -use crate::models::algebraic::{ILP, LinearConstraint, ObjectiveSense}; +use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::SteinerTree; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; @@ -142,8 +142,8 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>::reduce_to(&problem); let target_solution = vec![ - 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, 0, 0, 0, 0, 0, + 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, + 0, 0, 0, 0, 0, ]; assert_eq!( From ef725c10d7200342c3117b20a5c1d58acec20328 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 21 Mar 2026 03:10:01 +0800 Subject: [PATCH 8/9] 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) --- docs/paper/references.bib | 71 --------------------------------------- 1 file changed, 71 deletions(-) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 02c1d27b9..dc2a63d5b 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1188,55 +1188,6 @@ @article{schaefer1978 doi = {10.1145/800133.804350} } -@phdthesis{booth1975, - author = {Booth, Kellogg S.}, - title = {{PQ}-Tree Algorithms}, - school = {University of California, Berkeley}, - year = {1975} -} - -@article{booth1976, - author = {Booth, Kellogg S. and Lueker, George S.}, - title = {Testing for the consecutive ones property, interval graphs, and graph planarity using {PQ}-tree algorithms}, - journal = {Journal of Computer and System Sciences}, - volume = {13}, - number = {3}, - pages = {335--379}, - year = {1976} -} - -@article{lawler1972, - author = {Eugene L. Lawler}, - title = {A Procedure for Computing the $K$ Best Solutions to Discrete Optimization Problems and Its Application to the Shortest Path Problem}, - journal = {Management Science}, - volume = {18}, - number = {7}, - pages = {401--405}, - year = {1972}, - doi = {10.1287/mnsc.18.7.401} -} - -@article{eppstein1992, - author = {David Eppstein}, - title = {Finding the $k$ Smallest Spanning Trees}, - journal = {BIT}, - volume = {32}, - number = {2}, - pages = {237--248}, - year = {1992}, - doi = {10.1007/BF01994880} -} - -@article{chopra1996, - author = {Sunil Chopra and Jonathan H. Owen}, - title = {Extended formulations for the A-cut problem}, - journal = {Mathematical Programming}, - volume = {73}, - pages = {7--30}, - year = {1996}, - doi = {10.1007/BF02592096} -} - @article{wong1984steiner, author = {R. T. Wong}, title = {A Dual Ascent Approach for Steiner Tree Problems on a Directed Graph}, @@ -1259,28 +1210,6 @@ @article{kochmartin1998steiner doi = {10.1002/(SICI)1097-0037(199810)32:3<207::AID-NET5>3.0.CO;2-O} } -@article{kou1977, - author = {Lawrence T. Kou}, - title = {Polynomial Complete Consecutive Information Retrieval Problems}, - journal = {SIAM Journal on Computing}, - volume = {6}, - number = {1}, - pages = {67--75}, - year = {1977}, - doi = {10.1137/0206005} -} - -@article{boothlueker1976, - author = {Kellogg S. Booth and George S. Lueker}, - title = {Testing for the Consecutive Ones Property, Interval Graphs, and Graph Planarity Using {PQ}-Tree Algorithms}, - journal = {Journal of Computer and System Sciences}, - volume = {13}, - number = {3}, - pages = {335--379}, - year = {1976}, - doi = {10.1016/S0022-0000(76)80045-1} -} - @inproceedings{stockmeyer1973, author = {Larry J. Stockmeyer and Albert R. Meyer}, title = {Word Problems Requiring Exponential Time: Preliminary Report}, From 077f14e0001d9af40df7dc2bed76db09c4f9d150 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 21 Mar 2026 03:16:11 +0800 Subject: [PATCH 9/9] Add remark on zero-weight edge exclusion in SteinerTree->ILP paper section Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 719b92c19..a412a3a29 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -5373,6 +5373,8 @@ The following reductions to Integer Linear Programming are straightforward formu _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)$. _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. + + _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. ] == Unit Disk Mapping