Skip to main content

pounce_presolve/
reduction_frame.rs

1//! Postsolve frame stack for the auxiliary-equality preprocessing
2//! pass.
3//!
4//! PR 7 of the auxiliary-presolve port (issue #53). A
5//! [`ReductionFrame`] captures one layer of variable + row
6//! elimination:
7//!
8//! - `fixed_vars` — block variables fixed by the block solve.
9//! - `fixed_values` — their values at the fixed point.
10//! - `dropped_rows` — equality rows used to determine them.
11//! - `var_map / row_map` — index maps between full and reduced space.
12//!
13//! The headline method is
14//! [`ReductionFrame::recover_dropped_multipliers`], which solves the
15//! full-space KKT stationarity equations at the fixed variables for
16//! the missing multipliers. Assumption (matching ripopt v1): fixed
17//! variables are interior to their original bounds at the optimum,
18//! so `z_l = z_u = 0` for them.
19//!
20//! ripopt anchor: `src/reduction_frame.rs:101-231`.
21
22use pounce_common::types::Number;
23
24use crate::block_solve::{BlockSolveError, lu_factor_partial_pivot, lu_solve};
25
26/// One layer of the postsolve stack. Built once per accepted block
27/// elimination by PR 8's orchestrator.
28#[derive(Debug, Default, Clone)]
29pub struct ReductionFrame {
30    /// Inner-variable indices fixed by this layer, in ascending order.
31    pub fixed_vars: Vec<usize>,
32    /// Their values at the block-solve fixed point.
33    pub fixed_values: Vec<Number>,
34    /// Inner equality-row indices dropped by this layer, in
35    /// ascending order. `dropped_rows.len() == fixed_vars.len()`.
36    pub dropped_rows: Vec<usize>,
37    /// `var_map[i] = Some(reduced_idx)` if `i` survives this layer,
38    /// `None` if `i` is in `fixed_vars`.
39    pub var_map: Vec<Option<usize>>,
40    /// Same for rows.
41    pub row_map: Vec<Option<usize>>,
42}
43
44impl ReductionFrame {
45    /// Build a frame from the (sorted) lists of fixed variables /
46    /// values / dropped rows and the **full-space** problem shape.
47    pub fn new(
48        n_vars: usize,
49        n_rows: usize,
50        fixed_vars: Vec<usize>,
51        fixed_values: Vec<Number>,
52        dropped_rows: Vec<usize>,
53    ) -> Self {
54        assert_eq!(
55            fixed_vars.len(),
56            fixed_values.len(),
57            "fixed_vars and fixed_values must be the same length"
58        );
59        assert_eq!(
60            fixed_vars.len(),
61            dropped_rows.len(),
62            "fixed_vars and dropped_rows must be the same length (square block)"
63        );
64
65        // Mark fixed positions on flat `bool` vectors (O(1) lookup);
66        // BTreeSet would cost O(log k) per probe. PR review #60.
67        let mut is_fixed_var = vec![false; n_vars];
68        for &i in &fixed_vars {
69            is_fixed_var[i] = true;
70        }
71        let mut is_dropped_row = vec![false; n_rows];
72        for &i in &dropped_rows {
73            is_dropped_row[i] = true;
74        }
75
76        let mut var_map = vec![None; n_vars];
77        let mut next_reduced = 0;
78        for (i, slot) in var_map.iter_mut().enumerate().take(n_vars) {
79            if is_fixed_var[i] {
80                continue;
81            }
82            *slot = Some(next_reduced);
83            next_reduced += 1;
84        }
85
86        let mut row_map = vec![None; n_rows];
87        let mut next_reduced_row = 0;
88        for (i, slot) in row_map.iter_mut().enumerate().take(n_rows) {
89            if is_dropped_row[i] {
90                continue;
91            }
92            *slot = Some(next_reduced_row);
93            next_reduced_row += 1;
94        }
95
96        Self {
97            fixed_vars,
98            fixed_values,
99            dropped_rows,
100            var_map,
101            row_map,
102        }
103    }
104
105    pub fn n_full_vars(&self) -> usize {
106        self.var_map.len()
107    }
108
109    pub fn n_full_rows(&self) -> usize {
110        self.row_map.len()
111    }
112
113    pub fn n_reduced_vars(&self) -> usize {
114        self.n_full_vars() - self.fixed_vars.len()
115    }
116
117    pub fn n_reduced_rows(&self) -> usize {
118        self.n_full_rows() - self.dropped_rows.len()
119    }
120
121    /// Project a full-space `x` vector into reduced space (drop the
122    /// fixed entries).
123    pub fn project_x(&self, x_full: &[Number]) -> Vec<Number> {
124        assert_eq!(x_full.len(), self.n_full_vars());
125        self.var_map
126            .iter()
127            .zip(x_full.iter())
128            .filter_map(|(slot, &v)| slot.map(|_| v))
129            .collect()
130    }
131
132    /// Lift a reduced `x` back to full space, splicing the fixed
133    /// values back into their original positions.
134    pub fn lift_x(&self, x_reduced: &[Number]) -> Vec<Number> {
135        assert_eq!(x_reduced.len(), self.n_reduced_vars());
136        let mut out = vec![0.0; self.n_full_vars()];
137        for (i, slot) in self.var_map.iter().enumerate() {
138            if let Some(r) = slot {
139                out[i] = x_reduced[*r];
140            }
141        }
142        for (k, &i) in self.fixed_vars.iter().enumerate() {
143            out[i] = self.fixed_values[k];
144        }
145        out
146    }
147
148    /// Project a full-space λ vector into reduced space.
149    pub fn project_lambda(&self, lambda_full: &[Number]) -> Vec<Number> {
150        assert_eq!(lambda_full.len(), self.n_full_rows());
151        self.row_map
152            .iter()
153            .zip(lambda_full.iter())
154            .filter_map(|(slot, &v)| slot.map(|_| v))
155            .collect()
156    }
157
158    /// Lift a reduced λ back to full space, with zeros at dropped
159    /// row indices. (Real values for dropped rows come from
160    /// [`Self::recover_dropped_multipliers`].)
161    pub fn lift_lambda(&self, lambda_reduced: &[Number]) -> Vec<Number> {
162        assert_eq!(lambda_reduced.len(), self.n_reduced_rows());
163        let mut out = vec![0.0; self.n_full_rows()];
164        for (i, slot) in self.row_map.iter().enumerate() {
165            if let Some(r) = slot {
166                out[i] = lambda_reduced[*r];
167            }
168        }
169        out
170    }
171
172    /// Recover the `k = fixed_vars.len()` dropped-row multipliers
173    /// via dense LU on the full-space KKT stationarity equations at
174    /// the fixed variables. Returns one entry per `self.dropped_rows`
175    /// (in the same order).
176    ///
177    /// Assumption: fixed variables are interior to their original
178    /// bounds at the optimum (so `z_l = z_u = 0` for them).
179    ///
180    /// # Inputs
181    ///
182    /// - `grad_f` — objective gradient at the full-space optimum
183    ///   (length `n_full_vars`).
184    /// - `jac_full_row_major` — dense full-space Jacobian
185    ///   `(n_full_rows × n_full_vars)` at the optimum.
186    /// - `lambda_full` — multipliers for kept rows; entries at
187    ///   dropped-row positions are ignored.
188    ///
189    /// # Example
190    ///
191    /// ```
192    /// use pounce_presolve::reduction_frame::ReductionFrame;
193    ///
194    /// // 1 var, 1 row, dropped:  c(x) = x - 3 = 0, obj f = 4 x.
195    /// // Stationarity:  4 - 1 * λ = 0  →  λ = 4.
196    /// let frame = ReductionFrame::new(1, 1, vec![0], vec![3.0], vec![0]);
197    /// let grad_f = [4.0];
198    /// let jac = [1.0];
199    /// let lambda_full = [0.0]; // dropped, ignored
200    /// let lam = frame
201    ///     .recover_dropped_multipliers(&grad_f, &jac, &lambda_full)
202    ///     .unwrap();
203    /// assert!((lam[0] - 4.0).abs() < 1e-12);
204    /// ```
205    pub fn recover_dropped_multipliers(
206        &self,
207        grad_f: &[Number],
208        jac_full_row_major: &[Number],
209        lambda_full: &[Number],
210    ) -> Result<Vec<Number>, BlockSolveError> {
211        let n_vars = self.n_full_vars();
212        let n_rows = self.n_full_rows();
213        assert_eq!(
214            jac_full_row_major.len(),
215            n_rows * n_vars,
216            "jac_full_row_major length mismatch"
217        );
218        // The recovery reads the Jacobian only at columns `i ∈ fixed_vars`,
219        // so the full dense layout is just one indexing convention; see
220        // `recover_dropped_multipliers_cols` for the column-compacted one.
221        self.recover_core(grad_f, lambda_full, |row, col| {
222            jac_full_row_major[row * n_vars + col]
223        })
224    }
225
226    /// Same recovery as [`recover_dropped_multipliers`], but reads the
227    /// Jacobian from a **column-compacted** dense buffer that holds only a
228    /// subset of the full columns. The recovery touches the Jacobian only at
229    /// the frame's `fixed_vars` columns (it never reads any other column), so
230    /// a caller that knows which columns matter can materialize an
231    /// `(n_full_rows × n_cols)` block instead of the full
232    /// `(n_full_rows × n_full_vars)` one — the difference between O(m·k) and
233    /// O(m·n) memory when `k = |fixed_vars|` is tiny next to `n` (issue M26).
234    ///
235    /// - `jac_cols_row_major` — dense `(n_full_rows × n_cols)`, row-major.
236    /// - `n_cols` — number of compacted columns (the row stride).
237    /// - `orig_to_compact` — length `n_full_vars`; maps an original column
238    ///   index to its position in the compacted buffer. Only the entries at
239    ///   `fixed_vars` are ever read, and the caller must place those columns
240    ///   in the buffer; entries for absent columns may be any value.
241    pub fn recover_dropped_multipliers_cols(
242        &self,
243        grad_f: &[Number],
244        jac_cols_row_major: &[Number],
245        n_cols: usize,
246        orig_to_compact: &[usize],
247        lambda_full: &[Number],
248    ) -> Result<Vec<Number>, BlockSolveError> {
249        let n_rows = self.n_full_rows();
250        assert_eq!(
251            jac_cols_row_major.len(),
252            n_rows * n_cols,
253            "jac_cols_row_major length mismatch"
254        );
255        assert_eq!(
256            orig_to_compact.len(),
257            self.n_full_vars(),
258            "orig_to_compact length mismatch"
259        );
260        self.recover_core(grad_f, lambda_full, |row, col| {
261            jac_cols_row_major[row * n_cols + orig_to_compact[col]]
262        })
263    }
264
265    /// Shared core of the multiplier recovery. `get(row, col)` returns the
266    /// full-space Jacobian entry `J[row][col]`; the two public wrappers differ
267    /// only in how they lay out the matrix behind that accessor. `get` is
268    /// invoked exclusively at `col ∈ fixed_vars`.
269    fn recover_core(
270        &self,
271        grad_f: &[Number],
272        lambda_full: &[Number],
273        get: impl Fn(usize, usize) -> Number,
274    ) -> Result<Vec<Number>, BlockSolveError> {
275        let n_rows = self.n_full_rows();
276        let k = self.fixed_vars.len();
277        assert_eq!(grad_f.len(), self.n_full_vars(), "grad_f length mismatch");
278        assert_eq!(lambda_full.len(), n_rows, "lambda_full length mismatch");
279
280        if k == 0 {
281            return Ok(Vec::new());
282        }
283
284        // Use `row_map` for O(1) "is row r dropped?" — set in
285        // `new()`, no BTreeSet needed (PR review #60).
286        // Build the k×k system M λ_dropped = rhs.
287        //   M[i_idx][j_idx] = J[dropped_rows[j_idx]][fixed_vars[i_idx]]
288        //   rhs[i_idx] = grad_f[fixed_vars[i_idx]]
289        //              - Σ_{r kept} J[r][fixed_vars[i_idx]] * lambda_full[r]
290        let mut matrix = vec![0.0; k * k];
291        for (i_idx, &i) in self.fixed_vars.iter().enumerate() {
292            for (j_idx, &dr) in self.dropped_rows.iter().enumerate() {
293                matrix[i_idx * k + j_idx] = get(dr, i);
294            }
295        }
296
297        let mut rhs = vec![0.0; k];
298        for (i_idx, &i) in self.fixed_vars.iter().enumerate() {
299            let mut sum = 0.0;
300            for r in 0..n_rows {
301                if self.row_map[r].is_none() {
302                    // Row was dropped.
303                    continue;
304                }
305                sum += get(r, i) * lambda_full[r];
306            }
307            rhs[i_idx] = grad_f[i] - sum;
308        }
309
310        let piv = lu_factor_partial_pivot(&mut matrix, k).map_err(|_| BlockSolveError::Singular)?;
311        lu_solve(&matrix, &piv, &mut rhs, k);
312        Ok(rhs)
313    }
314}
315
316/// LIFO stack of `ReductionFrame`s. Bottom-most frame represents the
317/// first elimination layer applied; top-most is the most recent.
318/// `finalize_solution` lifts from top to bottom.
319#[derive(Debug, Default, Clone)]
320pub struct ReductionStack {
321    frames: Vec<ReductionFrame>,
322}
323
324impl ReductionStack {
325    /// True when no reduction has been pushed (the no-op fast path).
326    pub fn is_empty(&self) -> bool {
327        self.frames.is_empty()
328    }
329
330    /// Number of layers currently on the stack.
331    pub fn len(&self) -> usize {
332        self.frames.len()
333    }
334
335    /// Push a frame onto the stack (most-recent end).
336    pub fn push(&mut self, frame: ReductionFrame) {
337        self.frames.push(frame);
338    }
339
340    /// Reference to the most-recently-pushed frame, if any.
341    pub fn top(&self) -> Option<&ReductionFrame> {
342        self.frames.last()
343    }
344
345    /// Iterate frames from top (most recent) to bottom (first). PR 8
346    /// uses this order when lifting a reduced solution back to the
347    /// original full space.
348    pub fn iter_top_down(&self) -> impl Iterator<Item = &ReductionFrame> {
349        self.frames.iter().rev()
350    }
351
352    /// Iterate frames in push order (bottom to top). Useful when
353    /// projecting full → reduced through the layers in the same
354    /// order they were applied.
355    pub fn iter_bottom_up(&self) -> impl Iterator<Item = &ReductionFrame> {
356        self.frames.iter()
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn frame_new_builds_maps_correctly() {
366        // 4 vars, 3 rows. fixed_vars=[1], dropped_rows=[0].
367        let frame = ReductionFrame::new(4, 3, vec![1], vec![42.0], vec![0]);
368        // var_map: [Some(0), None, Some(1), Some(2)]
369        assert_eq!(frame.var_map, vec![Some(0), None, Some(1), Some(2)]);
370        // row_map: [None, Some(0), Some(1)]
371        assert_eq!(frame.row_map, vec![None, Some(0), Some(1)]);
372        assert_eq!(frame.n_reduced_vars(), 3);
373        assert_eq!(frame.n_reduced_rows(), 2);
374    }
375
376    #[test]
377    fn frame_project_x_drops_fixed() {
378        let frame = ReductionFrame::new(3, 1, vec![1], vec![20.0], vec![0]);
379        let x_full = [10.0, 20.0, 30.0];
380        assert_eq!(frame.project_x(&x_full), vec![10.0, 30.0]);
381    }
382
383    #[test]
384    fn frame_lift_x_splices_fixed_values() {
385        let frame = ReductionFrame::new(3, 1, vec![1], vec![20.0], vec![0]);
386        let x_reduced = [10.0, 30.0];
387        assert_eq!(frame.lift_x(&x_reduced), vec![10.0, 20.0, 30.0]);
388    }
389
390    #[test]
391    fn frame_project_lift_x_roundtrip() {
392        let frame = ReductionFrame::new(4, 2, vec![0, 2], vec![1.0, 9.0], vec![0, 1]);
393        let x_full = [1.0, 5.0, 9.0, 7.0];
394        let reduced = frame.project_x(&x_full);
395        let lifted = frame.lift_x(&reduced);
396        assert_eq!(lifted, x_full);
397    }
398
399    #[test]
400    fn frame_project_lambda_drops_dropped() {
401        let frame = ReductionFrame::new(3, 3, vec![1], vec![20.0], vec![0]);
402        let lambda_full = [1.0, 2.0, 3.0];
403        assert_eq!(frame.project_lambda(&lambda_full), vec![2.0, 3.0]);
404    }
405
406    #[test]
407    fn frame_lift_lambda_zeros_dropped() {
408        let frame = ReductionFrame::new(3, 3, vec![1], vec![20.0], vec![0]);
409        let lambda_reduced = [2.0, 3.0];
410        assert_eq!(frame.lift_lambda(&lambda_reduced), vec![0.0, 2.0, 3.0]);
411    }
412
413    #[test]
414    fn recover_multipliers_singleton_linear() {
415        // 1 var, 1 row. c(x) = x - 3 = 0, f = 4 x.
416        // Stationarity: 4 - 1 * λ = 0 → λ = 4.
417        let frame = ReductionFrame::new(1, 1, vec![0], vec![3.0], vec![0]);
418        let lam = frame
419            .recover_dropped_multipliers(&[4.0], &[1.0], &[0.0])
420            .unwrap();
421        assert_eq!(lam.len(), 1);
422        assert!((lam[0] - 4.0).abs() < 1e-12);
423    }
424
425    #[test]
426    fn recover_multipliers_2x2_linear() {
427        // 2 vars, 2 rows, both dropped.
428        // J = [[1, 0], [1, 1]]
429        // grad_f = [2, 5]
430        // Stationarity (per fixed var i):
431        //   i=0: 2 - 1*λ0 - 1*λ1 = 0
432        //   i=1: 5 - 0*λ0 - 1*λ1 = 0
433        // → λ1 = 5, then λ0 = 2 - 5 = -3.
434        //
435        // Note our system is M λ = rhs with
436        //   M[i][j] = J[dropped[j]][fixed[i]]
437        //   M = [[1, 1], [0, 1]]
438        //   rhs = grad_f - 0 (no kept rows) = [2, 5]
439        // Solving M λ = rhs:
440        //   row 0: λ0 + λ1 = 2
441        //   row 1:         λ1 = 5
442        //   → λ1 = 5, λ0 = -3. ✓
443        let frame = ReductionFrame::new(2, 2, vec![0, 1], vec![1.0, 2.0], vec![0, 1]);
444        let jac = [1.0, 0.0, 1.0, 1.0]; // row-major
445        let grad_f = [2.0, 5.0];
446        let lam = frame
447            .recover_dropped_multipliers(&grad_f, &jac, &[0.0, 0.0])
448            .unwrap();
449        assert!((lam[0] - (-3.0)).abs() < 1e-12, "λ0 was {}", lam[0]);
450        assert!((lam[1] - 5.0).abs() < 1e-12, "λ1 was {}", lam[1]);
451    }
452
453    #[test]
454    fn recover_multipliers_with_kept_rows() {
455        // 2 vars, 2 rows. Row 0 dropped, row 1 kept.
456        // fixed_vars = [0]. Only x_0 is fixed.
457        // J = [[2,  3],   ← dropped (touches fixed var x_0 with J=2)
458        //      [4,  5]]   ← kept (touches x_0 with J=4, λ_kept = 0.5)
459        // grad_f[0] = 10.
460        // Stationarity at x_0: 10 - 2 * λ_dropped - 4 * 0.5 = 0
461        //   → 10 - 2 λ_dropped - 2 = 0 → λ_dropped = 4.
462        let frame = ReductionFrame::new(2, 2, vec![0], vec![1.0], vec![0]);
463        let jac = [2.0, 3.0, 4.0, 5.0];
464        let grad_f = [10.0, 0.0];
465        let lambda_full = [0.0, 0.5]; // entry 0 ignored
466        let lam = frame
467            .recover_dropped_multipliers(&grad_f, &jac, &lambda_full)
468            .unwrap();
469        assert_eq!(lam.len(), 1);
470        assert!((lam[0] - 4.0).abs() < 1e-12);
471    }
472
473    #[test]
474    fn recover_multipliers_singular_block_jacobian() {
475        // 2x2 with rank-1 block Jacobian.
476        let frame = ReductionFrame::new(2, 2, vec![0, 1], vec![0.0, 0.0], vec![0, 1]);
477        let jac = [1.0, 2.0, 2.0, 4.0]; // rank-1
478        let grad_f = [1.0, 2.0];
479        let err = frame
480            .recover_dropped_multipliers(&grad_f, &jac, &[0.0, 0.0])
481            .unwrap_err();
482        assert_eq!(err, BlockSolveError::Singular);
483    }
484
485    #[test]
486    fn recover_only_reads_fixed_var_columns() {
487        // M26 verification: the recovery indexes the Jacobian solely at the
488        // frame's `fixed_vars` columns, so a caller need only materialize
489        // those columns. Poison every *non-fixed* column with NaN and confirm
490        // the recovered multipliers are byte-for-byte identical to the clean
491        // run — proving the densified non-fixed columns are never read.
492        //
493        // 3 vars, 3 rows. fixed_vars = [0, 2], dropped_rows = [0, 1];
494        // var 1 is free, so column 1 is the one allowed to be garbage.
495        let frame = ReductionFrame::new(3, 3, vec![0, 2], vec![1.0, 2.0], vec![0, 1]);
496        let grad_f = [10.0, 4.0, 7.0];
497        let lambda_full = [0.0, 0.0, 0.5]; // row 2 kept
498        let clean = [
499            2.0, 1.0, 0.5, // row 0
500            1.0, -1.0, 3.0, // row 1
501            0.4, 1.0, 0.9, // row 2 (kept)
502        ];
503        let expected = frame
504            .recover_dropped_multipliers(&grad_f, &clean, &lambda_full)
505            .unwrap();
506
507        let mut poisoned = clean;
508        for r in 0..3 {
509            poisoned[r * 3 + 1] = Number::NAN; // column 1 = the free var
510        }
511        let got = frame
512            .recover_dropped_multipliers(&grad_f, &poisoned, &lambda_full)
513            .unwrap();
514
515        assert_eq!(got.len(), expected.len());
516        for (g, e) in got.iter().zip(expected.iter()) {
517            assert!(g.is_finite(), "recovered multiplier went NaN: {g}");
518            assert_eq!(g.to_bits(), e.to_bits(), "got {g}, expected {e}");
519        }
520    }
521
522    #[test]
523    fn recover_cols_matches_dense() {
524        // M26: the column-compacted recovery must reproduce the dense one
525        // exactly. Build a dense Jacobian, then a compacted buffer holding
526        // only the fixed-var columns, and assert identical multipliers.
527        let frame = ReductionFrame::new(3, 3, vec![0, 2], vec![1.0, 2.0], vec![0, 1]);
528        let grad_f = [10.0, 4.0, 7.0];
529        let lambda_full = [0.0, 0.0, 0.5];
530        let dense = [
531            2.0, 1.0, 0.5, // row 0
532            1.0, -1.0, 3.0, // row 1
533            0.4, 1.0, 0.9, // row 2
534        ];
535        let dense_lam = frame
536            .recover_dropped_multipliers(&grad_f, &dense, &lambda_full)
537            .unwrap();
538
539        // Compact only columns {0, 2} (the union of fixed_vars).
540        let needed = [0usize, 2];
541        let n_cols = needed.len();
542        let mut orig_to_compact = [usize::MAX; 3];
543        for (cc, &c) in needed.iter().enumerate() {
544            orig_to_compact[c] = cc;
545        }
546        let mut jac_cols = vec![0.0; 3 * n_cols];
547        for r in 0..3 {
548            for (cc, &c) in needed.iter().enumerate() {
549                jac_cols[r * n_cols + cc] = dense[r * 3 + c];
550            }
551        }
552        let cols_lam = frame
553            .recover_dropped_multipliers_cols(
554                &grad_f,
555                &jac_cols,
556                n_cols,
557                &orig_to_compact,
558                &lambda_full,
559            )
560            .unwrap();
561
562        assert_eq!(cols_lam.len(), dense_lam.len());
563        for (c, d) in cols_lam.iter().zip(dense_lam.iter()) {
564            assert_eq!(c.to_bits(), d.to_bits(), "cols {c} != dense {d}");
565        }
566    }
567
568    #[test]
569    fn recover_cols_empty_frame() {
570        // No fixed vars → no columns needed → empty compact buffer is valid.
571        let frame = ReductionFrame::new(2, 2, vec![], vec![], vec![]);
572        let lam = frame
573            .recover_dropped_multipliers_cols(&[0.0; 2], &[], 0, &[usize::MAX; 2], &[0.0; 2])
574            .unwrap();
575        assert!(lam.is_empty());
576    }
577
578    #[test]
579    fn recover_multipliers_empty_frame() {
580        let frame = ReductionFrame::new(2, 2, vec![], vec![], vec![]);
581        let lam = frame
582            .recover_dropped_multipliers(&[0.0; 2], &[0.0; 4], &[0.0; 2])
583            .unwrap();
584        assert!(lam.is_empty());
585    }
586
587    #[test]
588    fn kkt_residual_after_recovery_to_1e_minus_12() {
589        // 3 vars (b1, b2, y), 3 rows.
590        //   row 0 (dropped):     2 b1 + b2     - 3       = 0  → at fixed point.
591        //   row 1 (dropped):     b1   - b2     + 1       = 0  → at fixed point.
592        //   row 2 (kept):        b1   + b2 + y - 5       = 0
593        // Solving the two dropped rows: b1 = 2/3, b2 = 5/3.
594        // Then row 2: y = 5 - 7/3 = 8/3 ≈ 2.667.
595        // Objective f = 10 b1 + 4 b2 + y².
596        // grad_f at (b1, b2, y) = (10, 4, 2y).
597        //
598        // The IPM-style reduced problem keeps row 2 active and var y
599        // free. We need to recover λ_0, λ_1 (for the dropped rows)
600        // and verify full-space stationarity at b1, b2 (and y holds
601        // automatically from the reduced KKT).
602        let frame = ReductionFrame::new(3, 3, vec![0, 1], vec![2.0 / 3.0, 5.0 / 3.0], vec![0, 1]);
603        // Build the full row-major Jacobian at the optimum.
604        let jac = [
605            2.0, 1.0, 0.0, // row 0
606            1.0, -1.0, 0.0, // row 1
607            1.0, 1.0, 1.0, // row 2
608        ];
609        // Objective gradient at the optimum.
610        let y_star = 8.0 / 3.0;
611        let grad_f = [10.0, 4.0, 2.0 * y_star];
612        // Reduced problem's kept-row multipliers: at the optimum,
613        // stationarity at y is 2y - λ_2 = 0 → λ_2 = 2y = 16/3.
614        let lambda_kept_2 = 2.0 * y_star;
615        let lambda_full = [0.0, 0.0, lambda_kept_2];
616
617        let lam_dropped = frame
618            .recover_dropped_multipliers(&grad_f, &jac, &lambda_full)
619            .unwrap();
620        // Reconstruct the full λ.
621        let mut lambda_recovered = lambda_full;
622        for (k, &r) in frame.dropped_rows.iter().enumerate() {
623            lambda_recovered[r] = lam_dropped[k];
624        }
625        // Verify stationarity at b1, b2 to high precision.
626        for &i in &frame.fixed_vars {
627            let mut s = grad_f[i];
628            for r in 0..3 {
629                s -= jac[r * 3 + i] * lambda_recovered[r];
630            }
631            assert!(s.abs() < 1e-12, "stationarity at var {i} = {s}");
632        }
633    }
634
635    /// Fuzz: build a synthetic full-space KKT solution `(x*, λ*)`,
636    /// declare a random subset of variables "fixed" and the
637    /// matching subset of rows "dropped", then verify the multiplier
638    /// recovery reproduces the original λ at the dropped indices to
639    /// within 1e-10.
640    struct FuzzRng(u64);
641    impl FuzzRng {
642        fn new(seed: u64) -> Self {
643            Self(seed)
644        }
645        fn next_u64(&mut self) -> u64 {
646            self.0 = self
647                .0
648                .wrapping_mul(6364136223846793005)
649                .wrapping_add(1442695040888963407);
650            self.0 >> 32
651        }
652        fn unit(&mut self) -> Number {
653            let raw = (self.next_u64() & 0x3fff_ffff) as Number;
654            raw / (1u64 << 29) as Number - 1.0
655        }
656    }
657
658    #[test]
659    fn frame_fuzz_recover_reproduces_synthetic_lambda() {
660        let mut rng = FuzzRng::new(0xface_b00c_baad_f00d);
661
662        for trial in 0..30 {
663            let n_vars = 2 + (rng.next_u64() % 3) as usize; // 2..=4
664            let n_rows = n_vars;
665            let k = 1 + (rng.next_u64() % n_vars as u64) as usize;
666
667            let mut perm_v: Vec<usize> = (0..n_vars).collect();
668            for i in (1..n_vars).rev() {
669                let j = (rng.next_u64() as usize) % (i + 1);
670                perm_v.swap(i, j);
671            }
672            let mut fixed_vars: Vec<usize> = perm_v[..k].to_vec();
673            fixed_vars.sort_unstable();
674
675            let mut perm_r: Vec<usize> = (0..n_rows).collect();
676            for i in (1..n_rows).rev() {
677                let j = (rng.next_u64() as usize) % (i + 1);
678                perm_r.swap(i, j);
679            }
680            let mut dropped_rows: Vec<usize> = perm_r[..k].to_vec();
681            dropped_rows.sort_unstable();
682
683            let mut jac = vec![0.0; n_rows * n_vars];
684            for r in 0..n_rows {
685                for c in 0..n_vars {
686                    jac[r * n_vars + c] = 0.2 * rng.unit();
687                }
688            }
689            for (&r, &c) in dropped_rows.iter().zip(fixed_vars.iter()) {
690                jac[r * n_vars + c] += 2.5;
691            }
692
693            let lambda_star: Vec<Number> = (0..n_rows).map(|_| rng.unit()).collect();
694            let mut grad_f = vec![0.0; n_vars];
695            let fixed_set: std::collections::BTreeSet<usize> = fixed_vars.iter().copied().collect();
696            for i in 0..n_vars {
697                if fixed_set.contains(&i) {
698                    let mut s = 0.0;
699                    for r in 0..n_rows {
700                        s += jac[r * n_vars + i] * lambda_star[r];
701                    }
702                    grad_f[i] = s;
703                } else {
704                    grad_f[i] = rng.unit();
705                }
706            }
707
708            let dropped_set: std::collections::BTreeSet<usize> =
709                dropped_rows.iter().copied().collect();
710            let mut lambda_given = vec![0.0; n_rows];
711            for r in 0..n_rows {
712                if !dropped_set.contains(&r) {
713                    lambda_given[r] = lambda_star[r];
714                }
715            }
716
717            let frame = ReductionFrame::new(
718                n_vars,
719                n_rows,
720                fixed_vars.clone(),
721                vec![0.0; k],
722                dropped_rows.clone(),
723            );
724
725            let lam_dropped = frame
726                .recover_dropped_multipliers(&grad_f, &jac, &lambda_given)
727                .unwrap_or_else(|e| panic!("trial {trial}: {e:?}"));
728
729            for (idx, &r) in dropped_rows.iter().enumerate() {
730                let expected = lambda_star[r];
731                let got = lam_dropped[idx];
732                assert!(
733                    (expected - got).abs() < 1e-10,
734                    "trial {trial}: λ[{r}] expected {expected:.6}, got {got:.6}"
735                );
736            }
737        }
738    }
739
740    #[test]
741    fn reduction_stack_push_top_iter() {
742        let mut stack = ReductionStack::default();
743        assert!(stack.is_empty());
744        let f1 = ReductionFrame::new(2, 2, vec![0], vec![1.0], vec![0]);
745        let f2 = ReductionFrame::new(2, 2, vec![1], vec![2.0], vec![1]);
746        stack.push(f1.clone());
747        stack.push(f2.clone());
748        assert_eq!(stack.len(), 2);
749        let top = stack.top().expect("non-empty");
750        assert_eq!(top.fixed_vars, f2.fixed_vars);
751        // top-down: f2, then f1.
752        let order: Vec<_> = stack.iter_top_down().map(|f| f.fixed_vars[0]).collect();
753        assert_eq!(order, vec![1, 0]);
754        let order_up: Vec<_> = stack.iter_bottom_up().map(|f| f.fixed_vars[0]).collect();
755        assert_eq!(order_up, vec![0, 1]);
756    }
757
758    /// PR #60 review nit: there was no paired test for `project_lambda`
759    /// + `lift_lambda` (only the directional tests). Confirm
760    /// project(lift(x)) is the identity on the reduced shape AND
761    /// lift(project(x)) zeroes the dropped indices but preserves
762    /// kept ones.
763    #[test]
764    fn frame_project_lift_lambda_roundtrip() {
765        let frame = ReductionFrame::new(4, 3, vec![0, 2], vec![1.0, 9.0], vec![0, 1]);
766        // Full lambda with arbitrary values; project then lift.
767        let lambda_full = [4.0, 5.0, 6.0];
768        let reduced = frame.project_lambda(&lambda_full);
769        // Reduced has one row (the only kept row, row 2).
770        assert_eq!(reduced, vec![6.0]);
771        // Lifting back zeroes the dropped row entries.
772        let lifted = frame.lift_lambda(&reduced);
773        assert_eq!(lifted, vec![0.0, 0.0, 6.0]);
774        // Now the other direction: project the lifted lambda back
775        // to reduced — should be the identity on reduced shape.
776        let reduced_again = frame.project_lambda(&lifted);
777        assert_eq!(reduced_again, reduced);
778    }
779
780    /// Multi-frame `ReductionStack` round-trip. Push two frames
781    /// (mutually compatible — they fix disjoint vars and drop
782    /// disjoint rows). Verify lift_x and lift_lambda compose
783    /// consistently when walked through both frames.
784    #[test]
785    fn reduction_stack_multi_frame_roundtrip() {
786        // Full shape: 4 vars, 4 rows.
787        // Frame 1 (bottom): fixes var 0 (= 10), drops row 0.
788        // Frame 2 (top):    fixes var 2 (= 30), drops row 2.
789        let f1 = ReductionFrame::new(4, 4, vec![0], vec![10.0], vec![0]);
790        let f2 = ReductionFrame::new(4, 4, vec![2], vec![30.0], vec![2]);
791        let mut stack = ReductionStack::default();
792        stack.push(f1.clone());
793        stack.push(f2.clone());
794
795        // Synthesize a "fully-lifted" x_full where the survivors
796        // (vars 1, 3) and rows (1, 3) carry known values.
797        let x_full_expected = vec![10.0, 7.0, 30.0, 5.0];
798        let lambda_full_expected = vec![0.0, 8.0, 0.0, 6.0];
799
800        // Project through both frames in bottom-up order, then
801        // lift back top-down. Result must equal original at the
802        // surviving entries (and frame-supplied values at fixed
803        // entries / zeros at dropped row indices).
804        //
805        // For this test we don't have stacked reduced shapes
806        // (each frame is independently 4-var/4-row); we just
807        // confirm each frame's lift drops the expected values
808        // back when walked individually via the stack's iterator.
809        for frame in stack.iter_top_down() {
810            let reduced_x = frame.project_x(&x_full_expected);
811            let lifted_x = frame.lift_x(&reduced_x);
812            assert_eq!(lifted_x, x_full_expected);
813            let reduced_l = frame.project_lambda(&lambda_full_expected);
814            let lifted_l = frame.lift_lambda(&reduced_l);
815            // Dropped index should be 0 in the lift; survivors
816            // preserve their values.
817            for r in 0..4 {
818                if frame.row_map[r].is_some() {
819                    assert_eq!(lifted_l[r], lambda_full_expected[r]);
820                } else {
821                    assert_eq!(lifted_l[r], 0.0);
822                }
823            }
824        }
825    }
826}