Skip to main content

pipeline_demo/
pipeline_demo.rs

1//! End-to-end pipeline worked example — PRs 2-7 of issue #53.
2//!
3//! Hand-crafted 4-variable problem mixing several elimination
4//! patterns and a non-trivial objective:
5//!
6//! ```text
7//!   min   f(a, b, c, d) = (a - 1)² + (b - 2)² + (c - 3)² + d²
8//!   s.t.  c0:  a            = 1
9//!         c1:  b + c        = 5
10//!         c2:  b - c        = -1
11//!         c3:  a + b + d    = 4
12//! ```
13//!
14//! All four constraints are equalities, the system is square and
15//! non-singular, and BTF decomposes it into three elimination
16//! blocks in order:
17//!
18//! - Block 0: singleton `{row 0 ↔ a}` → a = 1.
19//! - Block 1: 2-cycle `{rows 1, 2 ↔ b, c}` → Newton solves (b, c) = (2, 3).
20//! - Block 2: singleton `{row 3 ↔ d}` (depends on a and b from
21//!   earlier blocks) → d = 1.
22//!
23//! The reduced problem is empty — presolve solved the entire system
24//! by structural elimination. The example then performs multiplier
25//! recovery and verifies the full-space KKT residual is zero (to
26//! floating-point precision).
27//!
28//! Run with:
29//! ```bash
30//! cargo run -p pounce-presolve --example pipeline_demo
31//! ```
32
33#![allow(clippy::expect_used, clippy::unwrap_used)]
34
35use pounce_common::types::Number;
36use pounce_presolve::matching::hopcroft_karp;
37use pounce_presolve::{
38    classify_block, AuxiliaryCouplingClass, BlockEquations, BlockSolveOptions, BlockSolver,
39    BlockTriangularForm, DampedNewtonSolver, DulmageMendelsohnPartition, EqualityIncidence,
40    InequalityIncidence, ProbeView, ReductionFrame, SquareComponents,
41};
42
43struct LinearBlockEqs {
44    a: Vec<Number>,
45    b: Vec<Number>,
46    n: usize,
47}
48impl BlockEquations for LinearBlockEqs {
49    fn dim(&self) -> usize {
50        self.n
51    }
52    fn eval(&mut self, x: &[Number], f: &mut [Number]) -> bool {
53        for i in 0..self.n {
54            let mut s = -self.b[i];
55            for j in 0..self.n {
56                s += self.a[i * self.n + j] * x[j];
57            }
58            f[i] = s;
59        }
60        true
61    }
62    fn jacobian(&mut self, _x: &[Number], j: &mut [Number]) -> bool {
63        j.copy_from_slice(&self.a);
64        true
65    }
66}
67
68fn main() {
69    let n_vars = 4; // a, b, c, d
70    let n_rows = 4;
71    // Jacobian sparsity (row-major).
72    let jac_pattern = [
73        (0, 0), // c0: a
74        (1, 1),
75        (1, 2), // c1: b + c
76        (2, 1),
77        (2, 2), // c2: b - c
78        (3, 0),
79        (3, 1),
80        (3, 3), // c3: a + b + d
81    ];
82    let jac_irow: Vec<i32> = jac_pattern.iter().map(|(r, _)| *r as i32).collect();
83    let jac_jcol: Vec<i32> = jac_pattern.iter().map(|(_, c)| *c as i32).collect();
84    let g_l = [1.0, 5.0, -1.0, 4.0];
85    let g_u = [1.0, 5.0, -1.0, 4.0]; // all equalities
86
87    let probe = ProbeView {
88        n_vars,
89        m_rows: n_rows,
90        jac_irow: &jac_irow,
91        jac_jcol: &jac_jcol,
92        jac_values: None,
93        g_l: &g_l,
94        g_u: &g_u,
95        linearity: None,
96        one_based: false,
97        eq_tol: 1e-12,
98        excluded_vars: None,
99        excluded_rows: None,
100    };
101
102    println!("pipeline_demo — PRs 2-7 of pounce#53");
103    println!("======================================");
104
105    // ----- Stage 1: incidence + matching -----
106    let inc = EqualityIncidence::from_probe(&probe);
107    println!(
108        "\n[Stage 1] EqualityIncidence: {} eq rows, {} vars",
109        inc.n_eq_rows(),
110        inc.n_vars
111    );
112    let m = hopcroft_karp(&inc);
113    println!("[Stage 1] Matching: size = {}", m.size);
114
115    // ----- Stage 2: DM partition -----
116    let dm = DulmageMendelsohnPartition::from_matching(&inc, &m);
117    println!("\n[Stage 2] DM partition:");
118    println!("   over rows={:?}, cols={:?}", dm.over_rows, dm.over_cols);
119    println!(
120        "   square rows={:?}, cols={:?}",
121        dm.square_rows, dm.square_cols
122    );
123    println!(
124        "   under rows={:?}, cols={:?}",
125        dm.under_rows, dm.under_cols
126    );
127
128    // ----- Stage 3: components -----
129    let comps = SquareComponents::of_square_part(&inc, &m, &dm);
130    println!(
131        "\n[Stage 3] Components: {} square block(s)",
132        comps.components.len()
133    );
134    for (i, c) in comps.components.iter().enumerate() {
135        println!("   component {i}: rows={:?}, cols={:?}", c.eq_rows, c.cols);
136    }
137
138    // ----- Stage 4: BTF -----
139    println!("\n[Stage 4] BTF (per component):");
140    let ineq = InequalityIncidence::from_probe(&probe);
141
142    // Walk each component's BTF, solve each block, build the frame.
143    let mut all_fixed_vars: Vec<usize> = Vec::new();
144    let mut all_fixed_values: Vec<Number> = Vec::new();
145    let mut all_dropped_rows: Vec<usize> = Vec::new();
146    let mut x_running = vec![0.0; n_vars];
147    // Full A and b for this problem, used both for block extraction
148    // and the final KKT check.
149    let a_full = [
150        [1.0, 0.0, 0.0, 0.0],
151        [0.0, 1.0, 1.0, 0.0],
152        [0.0, 1.0, -1.0, 0.0],
153        [1.0, 1.0, 0.0, 1.0],
154    ];
155    let b_full = [1.0, 5.0, -1.0, 4.0];
156
157    for comp in &comps.components {
158        let btf = BlockTriangularForm::of_component(&inc, &m, comp);
159        for (bi, block) in btf.blocks.iter().enumerate() {
160            let class = classify_block(block, &ineq, &Default::default());
161            println!(
162                "   component cols={:?}, block {bi}: rows={:?}, cols={:?}, class={class:?}",
163                comp.cols, block.eq_rows, block.cols
164            );
165            assert_eq!(class, AuxiliaryCouplingClass::PureEquality);
166
167            // Build a small linear system for this block, splicing in
168            // the values of already-solved variables.
169            let k = block.eq_rows.len();
170            let mut a_block = vec![0.0; k * k];
171            let mut b_block = vec![0.0; k];
172            for (ii, &r) in block.eq_rows.iter().enumerate() {
173                let mut rhs = b_full[r];
174                for j in 0..n_vars {
175                    if let Some(jj) = block.cols.iter().position(|&c| c == j) {
176                        a_block[ii * k + jj] = a_full[r][j];
177                    } else {
178                        rhs -= a_full[r][j] * x_running[j];
179                    }
180                }
181                b_block[ii] = rhs;
182            }
183
184            // Solve.
185            let mut eqs = LinearBlockEqs {
186                a: a_block,
187                b: b_block,
188                n: k,
189            };
190            let mut solver = DampedNewtonSolver;
191            let opt = BlockSolveOptions::default();
192            let out = solver.solve(&vec![0.0; k], &mut eqs, &opt).unwrap();
193
194            // Record the block's solution.
195            for (ii, &c) in block.cols.iter().enumerate() {
196                x_running[c] = out.x[ii];
197                all_fixed_vars.push(c);
198                all_fixed_values.push(out.x[ii]);
199            }
200            for &r in &block.eq_rows {
201                all_dropped_rows.push(r);
202            }
203        }
204    }
205
206    // ----- Stage 5: lift via the reduction frame -----
207    println!("\n[Stage 5] After block solves:");
208    println!("   x_running (a, b, c, d) = {:?}", x_running);
209    // For this demo we declare ALL fixed vars and rows as one
210    // composite frame — in the real PR 8 orchestrator, each
211    // elimination layer becomes its own frame on the stack.
212    // Sort by variable index for the frame's contract.
213    let mut order: Vec<usize> = (0..all_fixed_vars.len()).collect();
214    order.sort_by_key(|&i| all_fixed_vars[i]);
215    let fixed_vars_sorted: Vec<usize> = order.iter().map(|&i| all_fixed_vars[i]).collect();
216    let fixed_values_sorted: Vec<Number> = order.iter().map(|&i| all_fixed_values[i]).collect();
217    let mut dropped_rows_sorted = all_dropped_rows.clone();
218    dropped_rows_sorted.sort_unstable();
219
220    let frame = ReductionFrame::new(
221        n_vars,
222        n_rows,
223        fixed_vars_sorted.clone(),
224        fixed_values_sorted,
225        dropped_rows_sorted.clone(),
226    );
227    println!(
228        "   ReductionFrame: fixed_vars={:?}, dropped_rows={:?}",
229        frame.fixed_vars, frame.dropped_rows
230    );
231    println!(
232        "   reduced shape: {} vars × {} rows",
233        frame.n_reduced_vars(),
234        frame.n_reduced_rows()
235    );
236
237    // For this problem BTF decomposed every row into a block — the
238    // reduced shape is 0×0 and no IPM step is needed. The lift is
239    // trivial: x_full = the values the block solves produced.
240    let x_reduced: Vec<Number> = Vec::new();
241    println!("\n[Stage 6] Reduced problem is empty (presolve solved it all).");
242    let x_full = frame.lift_x(&x_reduced);
243    println!("   lifted x_full = {:?}", x_full);
244
245    // Sanity: should match (1, 2, 3, 1).
246    let expected = [1.0, 2.0, 3.0, 1.0];
247    let max_x_err = x_full
248        .iter()
249        .zip(expected.iter())
250        .map(|(a, b)| (a - b).abs())
251        .fold(0.0_f64, f64::max);
252    println!("   max |x - expected|: {:.3e}", max_x_err);
253    assert!(max_x_err < 1e-9);
254
255    // ----- Stage 7: multiplier recovery -----
256    // Objective: f = (a-1)² + (b-2)² + (c-3)² + d²
257    // ∇f at the optimum (1, 2, 3, 1): (0, 0, 0, 2).
258    let grad_f = [
259        2.0 * (x_full[0] - 1.0),
260        2.0 * (x_full[1] - 2.0),
261        2.0 * (x_full[2] - 3.0),
262        2.0 * x_full[3],
263    ];
264    println!("\n[Stage 7] ∇f at the optimum: {:?}", grad_f);
265
266    // No kept rows → reduced λ is empty.
267    let lambda_reduced: Vec<Number> = Vec::new();
268    let mut lambda_full = frame.lift_lambda(&lambda_reduced);
269    println!("   reduced λ = {:?}", lambda_reduced);
270    println!("   lifted λ (pre-recovery) = {:?}", lambda_full);
271
272    // Build full Jacobian for the recovery LU.
273    let mut jac_full = vec![0.0; n_rows * n_vars];
274    for (r, row) in a_full.iter().enumerate() {
275        for (c, &v) in row.iter().enumerate() {
276            jac_full[r * n_vars + c] = v;
277        }
278    }
279    let lam_dropped = frame
280        .recover_dropped_multipliers(&grad_f, &jac_full, &lambda_full)
281        .unwrap();
282    for (idx, &r) in frame.dropped_rows.iter().enumerate() {
283        lambda_full[r] = lam_dropped[idx];
284    }
285    println!("   recovered λ (full) = {:?}", lambda_full);
286
287    // Final KKT residual at every variable.
288    println!("\n[Stage 8] Full-space KKT residual:");
289    let mut max_kkt: Number = 0.0;
290    for i in 0..n_vars {
291        let mut s = grad_f[i];
292        for r in 0..n_rows {
293            s -= jac_full[r * n_vars + i] * lambda_full[r];
294        }
295        println!("   ∂L/∂x[{i}] = {:.3e}", s);
296        max_kkt = max_kkt.max(s.abs());
297    }
298    println!("   max |KKT residual| = {:.3e}", max_kkt);
299    assert!(max_kkt < 1e-10);
300    println!("\n✓ Pipeline end-to-end correct.");
301}