Skip to main content

pounce_sens_core/
boundcheck.rs

1//! Holding the parametric sensitivity step inside the variable bounds.
2//!
3//! Mirrors upstream
4//! [`SensStdStepCalculator::BoundCheck`](https://github.com/coin-or/Ipopt/blob/master/contrib/sIPOPT/src/SensStdStepCalc.cpp),
5//! which is what `sens_boundcheck` turns on.
6//!
7//! A step can point outside the box. Clipping the offending coordinate
8//! back to its bound is cheap, but it leaves every other coordinate at
9//! its linear-predictor value, so the result satisfies the bounds and
10//! no longer satisfies the constraints. On upstream's own parametric
11//! example that costs an order of magnitude against a full re-solve.
12//!
13//! [`refine_step_onto_bounds`] instead adds a row pinning the offending
14//! coordinate at its bound and re-solves, so the others move with it.
15//! [`worst_violation`] picks which coordinate that is and
16//! [`expand_bounds`] puts the bounds in a form both can read.
17//!
18//! # Both halves, and why each matters
19//!
20//! Upstream's fix-relax is two cases (Pirnay, Lopez-Negrete and Biegler
21//! 2012, section 2.5), and the name refers to both. Its equation 17
22//! pins a variable the step carries past a bound, activating it. Its
23//! equation 18 sets a bound multiplier to zero when the step drives it
24//! negative, deactivating that bound so the variable can move.
25//!
26//! They fail differently. Without the pin, a crossing variable is
27//! clamped and every other one keeps a value computed as though it had
28//! not been. Without the release, a variable sitting on a bound stays
29//! there however hard the perturbation pulls it off, because the linear
30//! step preserves complementarity. Measured against sIPOPT on a model
31//! whose bound wants to release, that second case is the difference
32//! between returning 0.0 and 1.667.
33//!
34//! Both are solved the same way: add the row, re-solve the augmented
35//! system through the Schur complement over the added rows, which is
36//! what the paper's equations 19 through 22 describe.
37
38use crate::schur_data::IndexSchurData;
39use pounce_common::types::{Index, Number};
40use pounce_linalg::Vector;
41use pounce_linalg::expansion_matrix::ExpansionMatrix;
42use std::rc::Rc;
43
44/// Expand the compressed bound vectors into full var-x arrays, with
45/// infinities where a variable has no bound on that side.
46///
47/// The compressed form pairs an [`ExpansionMatrix`] with a dense vector
48/// holding only the bounded slots. Reading it repeatedly means holding
49/// a borrow of the NLP, which a caller that also re-solves cannot do,
50/// so this copies once.
51pub fn expand_bounds(
52    n_x: usize,
53    px_l: &Rc<dyn pounce_linalg::Matrix>,
54    px_u: &Rc<dyn pounce_linalg::Matrix>,
55    x_l: &dyn Vector,
56    x_u: &dyn Vector,
57) -> (Vec<Number>, Vec<Number>) {
58    let mut lo = vec![Number::NEG_INFINITY; n_x];
59    let mut hi = vec![Number::INFINITY; n_x];
60    for (pm, src, dst) in [(px_l, x_l, &mut lo), (px_u, x_u, &mut hi)] {
61        let Some(em) = pm.as_any().downcast_ref::<ExpansionMatrix>() else {
62            continue;
63        };
64        let vals = compressed_values(src);
65        for (ci, &full_pos) in em.expanded_pos_indices().iter().enumerate() {
66            let i = full_pos as usize;
67            if let (true, Some(&v)) = (i < n_x, vals.get(ci)) {
68                dst[i] = v;
69            }
70        }
71    }
72    (lo, hi)
73}
74
75/// Every coordinate whose predicted value leaves its bound, as
76/// `(index, the bound it leaves, how far past it)`, worst first.
77///
78/// This is the half of the bound check that fix-relax keeps. The clamp
79/// above answers "put it back", which loses the other coordinates; the
80/// refinement needs "which ones, and where do they belong", and then
81/// re-solves with those coordinates pinned so the rest respond.
82///
83/// Upstream's `BoundCheck` collects the whole list in one sweep and
84/// its caller pins all of it before re-solving, which is what makes
85/// the loop terminate on its own rather than on a pass budget: pinning
86/// the single worst one per pass needs as many passes as there are
87/// crossings, so on a model with more crossings than passes the budget
88/// decides the answer (gh#732).
89///
90/// The list is ordered by overshoot rather than by index so the pins do
91/// not depend on how the model was written, and ties keep index order.
92/// `skip` names coordinates already pinned by an earlier pass, which
93/// sit ON their bound and would otherwise be picked again.
94pub fn bound_violations(
95    x_curr: &[Number],
96    dx: &[Number],
97    lo: &[Number],
98    hi: &[Number],
99    eps: Number,
100    skip: &[usize],
101) -> Vec<(usize, Number, Number)> {
102    let mut out: Vec<(usize, Number, Number)> = Vec::new();
103    for i in 0..x_curr.len().min(dx.len()) {
104        if skip.contains(&i) {
105            continue;
106        }
107        let trial = x_curr[i] + dx[i];
108        let (bound, over) = if trial < lo[i] {
109            (lo[i], lo[i] - trial)
110        } else if trial > hi[i] {
111            (hi[i], trial - hi[i])
112        } else {
113            continue;
114        };
115        if over > eps {
116            out.push((i, bound, over));
117        }
118    }
119    // stable, so equal overshoots keep index order
120    out.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
121    out
122}
123
124/// The coordinate whose predicted value leaves its bound by the most,
125/// as `(index, the bound it leaves)`. The head of
126/// [`bound_violations`].
127pub fn worst_violation(
128    x_curr: &[Number],
129    dx: &[Number],
130    lo: &[Number],
131    hi: &[Number],
132    eps: Number,
133    skip: &[usize],
134) -> Option<(usize, Number)> {
135    bound_violations(x_curr, dx, lo, hi, eps, skip)
136        .first()
137        .map(|&(i, bound, _)| (i, bound))
138}
139
140/// Extract dense values from a `dyn Vector` that wraps a `DenseVector`.
141/// Returns an empty vector when the downcast fails (and the bound
142/// vector is just treated as having no entries — the boundcheck then
143/// silently no-ops, matching upstream's behavior when bounds aren't
144/// represented as DenseVectors).
145fn compressed_values(v: &dyn Vector) -> Vec<Number> {
146    use pounce_linalg::dense_vector::DenseVector;
147    match v.as_any().downcast_ref::<DenseVector>() {
148        // `expanded_values` (not `values`) so a homogeneous bound
149        // vector — e.g. every lower bound 0 — materializes its scalar
150        // instead of tripping `DenseVector::values`'s
151        // `!homogeneous` debug_assert (L16).
152        Some(dv) => dv.expanded_values(),
153        None => Vec::new(),
154    }
155}
156
157// Quieter index-typed signature helper for callers that pass usize-
158// dimensioned slices but receive Index-counted bound dimensions.
159#[doc(hidden)]
160pub fn _index_to_usize(i: Index) -> usize {
161    i as usize
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use pounce_linalg::Vector;
168    use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
169    use pounce_linalg::expansion_matrix::{ExpansionMatrix, ExpansionMatrixSpace};
170
171    fn make_dv(values: &[Number]) -> DenseVector {
172        let space = DenseVectorSpace::new(values.len() as Index);
173        let mut dv = DenseVector::new(space);
174        dv.values_mut().copy_from_slice(values);
175        dv
176    }
177
178    /// A homogeneous DenseVector of length `dim`, every entry `scalar`.
179    /// Built via `Vector::set`, which puts the vector in homogeneous
180    /// representation (no materialized storage) — the state under which
181    /// `DenseVector::values()` debug_asserts.
182    fn make_homogeneous_dv(dim: Index, scalar: Number) -> DenseVector {
183        let space = DenseVectorSpace::new(dim);
184        let mut dv = DenseVector::new(space);
185        dv.set(scalar);
186        assert!(dv.is_homogeneous());
187        dv
188    }
189
190    /// `(px, compressed)` for a bound present on the given positions.
191    fn expansion(n: Index, positions: &[Index]) -> Rc<dyn pounce_linalg::Matrix> {
192        let space = ExpansionMatrixSpace::new(n, positions.len() as Index, positions, 0);
193        Rc::new(ExpansionMatrix::new(space)) as Rc<dyn pounce_linalg::Matrix>
194    }
195
196    #[test]
197    fn expand_bounds_puts_infinity_where_a_bound_is_absent() {
198        // only x1 has a lower bound, only x2 an upper one
199        let (lo, hi) = expand_bounds(
200            3,
201            &expansion(3, &[1]),
202            &expansion(3, &[2]),
203            &make_dv(&[-2.0]),
204            &make_dv(&[7.0]),
205        );
206        assert_eq!(lo, vec![Number::NEG_INFINITY, -2.0, Number::NEG_INFINITY]);
207        assert_eq!(hi, vec![Number::INFINITY, Number::INFINITY, 7.0]);
208    }
209
210    #[test]
211    fn expand_bounds_materializes_a_homogeneous_vector() {
212        // every lower bound 0, stored as a scalar rather than an array
213        let (lo, _) = expand_bounds(
214            2,
215            &expansion(2, &[0, 1]),
216            &expansion(2, &[]),
217            &make_homogeneous_dv(2, 0.0),
218            &make_dv(&[]),
219        );
220        assert_eq!(lo, vec![0.0, 0.0]);
221    }
222
223    #[test]
224    fn worst_violation_takes_the_largest_overshoot_not_the_first() {
225        let x = [0.5, 0.5, 0.5];
226        let dx = [-0.6, -2.0, -0.7];
227        let lo = [0.0, 0.0, 0.0];
228        let hi = [10.0, 10.0, 10.0];
229        // x1 is out by 1.5, x0 by 0.1, x2 by 0.2
230        let (i, bound) = worst_violation(&x, &dx, &lo, &hi, 1e-9, &[]).unwrap();
231        assert_eq!(i, 1);
232        assert_eq!(bound, 0.0);
233    }
234
235    #[test]
236    fn worst_violation_skips_what_is_already_pinned() {
237        let x = [0.5, 0.5];
238        let dx = [-0.6, -2.0];
239        let lo = [0.0, 0.0];
240        let hi = [10.0, 10.0];
241        let (i, _) = worst_violation(&x, &dx, &lo, &hi, 1e-9, &[1]).unwrap();
242        assert_eq!(i, 0, "the worst one is pinned, so the next is taken");
243    }
244
245    #[test]
246    fn worst_violation_reports_an_upper_bound_too() {
247        let x = [0.5];
248        let dx = [3.0];
249        let (i, bound) = worst_violation(&x, &dx, &[0.0], &[1.0], 1e-9, &[]).unwrap();
250        assert_eq!((i, bound), (0, 1.0));
251    }
252
253    #[test]
254    fn worst_violation_is_none_inside_the_bounds_and_within_eps() {
255        let x = [0.5];
256        assert!(worst_violation(&x, &[0.1], &[0.0], &[1.0], 1e-9, &[]).is_none());
257        // just outside, but under the tolerance
258        assert!(worst_violation(&x, &[0.5 + 1e-12], &[0.0], &[1.0], 1e-9, &[]).is_none());
259    }
260
261    #[test]
262    fn bound_violations_returns_every_crossing_worst_first() {
263        let x = [0.5, 0.5, 0.5, 0.5];
264        let dx = [-0.6, -2.0, -0.7, 0.1];
265        let lo = [0.0; 4];
266        let hi = [10.0; 4];
267        let v = bound_violations(&x, &dx, &lo, &hi, 1e-9, &[]);
268        // x3 stays inside; the other three are out by 0.1, 1.5 and 0.2
269        assert_eq!(
270            v.iter().map(|&(i, _, _)| i).collect::<Vec<_>>(),
271            vec![1, 2, 0],
272            "the whole list, ordered by overshoot",
273        );
274        assert_eq!(v[0].1, 0.0, "and each carries the bound it left");
275    }
276
277    #[test]
278    fn bound_violations_leaves_out_what_is_already_pinned() {
279        let x = [0.5, 0.5];
280        let dx = [-0.6, -2.0];
281        let v = bound_violations(&x, &dx, &[0.0, 0.0], &[10.0, 10.0], 1e-9, &[1]);
282        assert_eq!(v.len(), 1);
283        assert_eq!(v[0].0, 0, "the pinned coordinate is not offered again");
284    }
285
286    /// `K` for a two-row system where holding row 0 drags row 1 by
287    /// `lever`: solving `K y = e0` gives `y = (1, -lever)`.
288    fn lever_backsolver(lever: Number) -> crate::backsolver::DenseLuBacksolver {
289        crate::backsolver::DenseLuBacksolver::from_dense(2, &[1.0, 0.0, lever, 1.0])
290            .expect("nonsingular")
291    }
292
293    #[test]
294    fn a_refinement_that_ends_further_out_returns_the_unrefined_step() {
295        // Row 0 is 0.1 below its bound, and the pin that repairs it
296        // throws row 1 a thousand times further out than that. The pass
297        // limit stops the loop before it can pin row 1 as well, so what
298        // it has to return is worse than what it started from —
299        // gh#732's "return the unrefined step" guard, which the pin's
300        // own achievement check cannot see, since row 0 lands exactly
301        // where it was asked to.
302        let bs = lever_backsolver(1000.0);
303        let dx_plain = [-0.1, 0.0];
304        let (dx, rows, stop) = refine_step_onto_bounds(
305            &bs,
306            &dx_plain,
307            &[0.0, 0.0],
308            &[0.0, -1e-3],
309            &[Number::INFINITY, 1e-3],
310            &[],
311            &[0.0, 0.0],
312            1e-9,
313            1e-9,
314            1,
315        )
316        .expect("refinement");
317        assert_eq!(stop, RefineStop::WorseThanPlain);
318        assert!(rows.is_empty(), "nothing is reported as constrained");
319        assert_eq!(dx, dx_plain.to_vec(), "the unrefined step comes back");
320    }
321
322    #[test]
323    fn a_pass_whose_correction_is_out_of_scale_is_refused() {
324        // The same shape with the lever at 1e10: the pin is achieved to
325        // the last digit and the correction is 1e9 times the step it
326        // corrects, which is a near-singular solve rather than a
327        // repair. A check that reads only the pinned row accepts it.
328        let bs = lever_backsolver(1e10);
329        let dx_plain = [-0.1, 0.0];
330        let (dx, rows, stop) = refine_step_onto_bounds(
331            &bs,
332            &dx_plain,
333            &[0.0, 0.0],
334            &[0.0, Number::NEG_INFINITY],
335            &[Number::INFINITY, Number::INFINITY],
336            &[],
337            &[0.0, 0.0],
338            1e-9,
339            1e-9,
340            8,
341        )
342        .expect("refinement");
343        assert_eq!(stop, RefineStop::DegreesOfFreedom);
344        assert!(
345            rows.is_empty(),
346            "the pass was refused, so nothing is pinned"
347        );
348        assert_eq!(dx, dx_plain.to_vec());
349    }
350
351    /// A backsolver whose release half is scripted: the plain solves go
352    /// through a `DenseLuBacksolver`, `solve_released_step` answers
353    /// from `steps` keyed by how many rows are released — a missing
354    /// entry is a factorization that failed — and every call to it is
355    /// counted.
356    #[derive(Clone)]
357    struct ScriptedRelease {
358        base: crate::backsolver::DenseLuBacksolver,
359        rows: Vec<crate::backsolver::BoundRow>,
360        steps: std::collections::BTreeMap<usize, Vec<Number>>,
361        calls: Rc<std::cell::Cell<usize>>,
362    }
363
364    impl crate::backsolver::SensBacksolver for ScriptedRelease {
365        fn dim(&self) -> usize {
366            self.base.dim()
367        }
368        fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
369            self.base.solve(rhs, lhs)
370        }
371        fn bound_rows(&self) -> Option<&[crate::backsolver::BoundRow]> {
372            Some(&self.rows)
373        }
374        fn supports_release(&self) -> bool {
375            true
376        }
377        fn solve_released(&self, _released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
378            self.base.solve(rhs, lhs)
379        }
380        fn solve_released_step(
381            &self,
382            released: &[usize],
383            _rhs: &[Number],
384            lhs: &mut [Number],
385        ) -> bool {
386            self.calls.set(self.calls.get() + 1);
387            match self.steps.get(&released.len()) {
388                Some(s) => {
389                    lhs.copy_from_slice(s);
390                    true
391                }
392                None => false,
393            }
394        }
395    }
396
397    /// `n × n` identity with `lever` at `(1, 0)`, so solving `K y = e0`
398    /// gives `y = (1, -lever, 0, …)`: pinning row 0 drags row 1.
399    fn lever_matrix(n: usize, lever: Number) -> Vec<Number> {
400        let mut a = vec![0.0; n * n];
401        for i in 0..n {
402            a[i * n + i] = 1.0;
403        }
404        a[n] = lever;
405        a
406    }
407
408    #[test]
409    fn a_release_batch_that_makes_the_step_worse_backs_off_to_one() {
410        // Two multipliers are negative. Releasing both takes x0 five
411        // below its lower bound; releasing the most negative one alone
412        // settles. The batch has to earn its place the way the pin
413        // batch does — without that, the CSTR of notebook 36 released
414        // 56 bounds where 41 were right and the step came back worse
415        // than not refining at all (gh#734 review).
416        let calls = Rc::new(std::cell::Cell::new(0));
417        let bs = ScriptedRelease {
418            base: crate::backsolver::DenseLuBacksolver::from_dense(4, &lever_matrix(4, 0.0))
419                .expect("nonsingular"),
420            rows: vec![
421                crate::backsolver::BoundRow {
422                    row: 2,
423                    var_row: 0,
424                    lower: true,
425                },
426                crate::backsolver::BoundRow {
427                    row: 3,
428                    var_row: 1,
429                    lower: true,
430                },
431            ],
432            steps: [
433                (2usize, vec![-5.0, 0.0, 0.0, 0.0]),
434                (1usize, vec![0.0, 0.0, 0.0, 0.0]),
435            ]
436            .into_iter()
437            .collect(),
438            calls: Rc::clone(&calls),
439        };
440        let mults = [
441            BoundMultiplier { row: 2, base: 1.0 },
442            BoundMultiplier { row: 3, base: 1.0 },
443        ];
444        // z2 = 1 - 2 = -1 and z3 = 1 - 1.5 = -0.5, so both want out
445        let (dx, rows, stop) = refine_step_onto_bounds(
446            &bs,
447            &[0.0, 0.0, -2.0, -1.5],
448            &[0.0, 0.0],
449            &[0.0, 0.0],
450            &[Number::INFINITY, Number::INFINITY],
451            &mults,
452            &[0.0; 4],
453            1e-9,
454            1e-9,
455            8,
456        )
457        .expect("refinement");
458        assert_eq!(rows, vec![2], "the most negative one, alone");
459        assert_eq!(stop, RefineStop::Settled);
460        assert_eq!(dx, vec![0.0, 0.0, -1.0, 0.0], "and its multiplier is zero");
461    }
462
463    /// The primal margin and the release threshold are two numbers.
464    /// A caller who says ten is on the bound has said nothing about
465    /// whether a multiplier at minus one has changed sign, and with one
466    /// number a wide `bound_eps` would stop every release on the model.
467    #[test]
468    fn a_wide_primal_margin_does_not_stop_a_release() {
469        let make = || ScriptedRelease {
470            base: crate::backsolver::DenseLuBacksolver::from_dense(4, &lever_matrix(4, 0.0))
471                .expect("nonsingular"),
472            rows: vec![
473                crate::backsolver::BoundRow {
474                    row: 2,
475                    var_row: 0,
476                    lower: true,
477                },
478                crate::backsolver::BoundRow {
479                    row: 3,
480                    var_row: 1,
481                    lower: true,
482                },
483            ],
484            steps: [
485                (2usize, vec![-5.0, 0.0, 0.0, 0.0]),
486                (1usize, vec![0.0, 0.0, 0.0, 0.0]),
487            ]
488            .into_iter()
489            .collect(),
490            calls: Rc::new(std::cell::Cell::new(0)),
491        };
492        let mults = [
493            BoundMultiplier { row: 2, base: 1.0 },
494            BoundMultiplier { row: 3, base: 1.0 },
495        ];
496        // z2 = 1 - 2 = -1 and z3 = -0.5 both want out. A primal margin
497        // of ten is wider than anything here, and both releases still
498        // happen. Both, rather than the one the sibling test above
499        // settles on: the accept guard compares overshoot against the
500        // primal margin, and five below a bound is inside ten, so the
501        // batch stands. That is the guard reading the caller's margin,
502        // which is the one number a caller who widens it has changed.
503        let (_, rows, stop) = refine_step_onto_bounds(
504            &make(),
505            &[0.0, 0.0, -2.0, -1.5],
506            &[0.0, 0.0],
507            &[0.0, 0.0],
508            &[Number::INFINITY, Number::INFINITY],
509            &mults,
510            &[0.0; 4],
511            10.0,
512            1e-9,
513            8,
514        )
515        .expect("refinement");
516        assert_eq!(rows, vec![2, 3], "the release reads its own threshold");
517        assert_eq!(stop, RefineStop::Settled);
518        // And the other way: a release threshold of ten is the one
519        // thing that stops it, with the primal margin back at its floor.
520        let (_, rows, _) = refine_step_onto_bounds(
521            &make(),
522            &[0.0, 0.0, -2.0, -1.5],
523            &[0.0, 0.0],
524            &[0.0, 0.0],
525            &[Number::INFINITY, Number::INFINITY],
526            &mults,
527            &[0.0; 4],
528            1e-9,
529            10.0,
530            8,
531        )
532        .expect("refinement");
533        assert!(rows.is_empty(), "nothing is negative past ten");
534    }
535
536    #[test]
537    fn a_release_the_factorization_refuses_is_not_asked_for_twice() {
538        // The one negative multiplier cannot be released at all. The
539        // pins carry on without it, and the loop neither asks for that
540        // factorization again on every later pass nor reports the pass
541        // limit for something no budget reaches.
542        let calls = Rc::new(std::cell::Cell::new(0));
543        let bs = ScriptedRelease {
544            base: crate::backsolver::DenseLuBacksolver::from_dense(4, &lever_matrix(4, 1.0))
545                .expect("nonsingular"),
546            rows: vec![crate::backsolver::BoundRow {
547                row: 2,
548                var_row: 0,
549                lower: true,
550            }],
551            // no entry for any size: every released factorization fails
552            steps: std::collections::BTreeMap::new(),
553            calls: Rc::clone(&calls),
554        };
555        let mults = [BoundMultiplier { row: 2, base: 1.0 }];
556        // x0 is 1.0 below its bound and z2 = 1 - 2 = -1 wants out.
557        // Pinning x0 drags x1 to -1, under ITS bound of -0.5, so a
558        // second pass follows and would ask for the release again.
559        let (_dx, rows, stop) = refine_step_onto_bounds(
560            &bs,
561            &[-1.0, 0.0, -2.0, 0.0],
562            &[0.0, 0.0],
563            &[0.0, -0.5],
564            &[Number::INFINITY, Number::INFINITY],
565            &mults,
566            &[0.0; 4],
567            1e-9,
568            1e-9,
569            8,
570        )
571        .expect("refinement");
572        assert_eq!(calls.get(), 1, "asked for once, then barred");
573        assert_eq!(
574            stop,
575            RefineStop::DegreesOfFreedom,
576            "a bound that cannot leave the active set is not the pass limit",
577        );
578        assert_eq!(rows, vec![0, 1], "and the pins it could place still stand");
579    }
580}
581
582/// A bound multiplier the step can drive negative: where it sits in the
583/// compound KKT vector, and its value at the base point.
584///
585/// A negative multiplier means the bound should no longer be active,
586/// which is the second half of upstream's fix-relax (its equation 18).
587pub struct BoundMultiplier {
588    /// Row of the compound KKT vector holding this multiplier.
589    pub row: usize,
590    /// Its value at the converged point, read raw off `curr.z_l` /
591    /// `curr.z_u`, so in the coordinates the solve ran in rather than
592    /// the model's own. [`refine_step_onto_bounds`] converts it with
593    /// the backsolver's own `F`, so every caller hands over the same
594    /// raw value and none of them needs to know the convention.
595    pub base: Number,
596}
597
598/// Why [`refine_step_onto_bounds`] stopped.
599///
600/// Only [`RefineStop::Settled`] says the refinement finished: the
601/// violation list emptied, which is the loop's own termination
602/// condition. Every other value says the step returned is the last one
603/// a pass could achieve, and names what stopped it, so a caller can
604/// tell a limit it may raise from one it cannot.
605#[derive(Clone, Copy, Debug, PartialEq, Eq)]
606pub enum RefineStop {
607    /// Nothing is outside a bound and no bound multiplier is negative.
608    Settled,
609    /// `max_iter` passes were spent with the list still not empty. A
610    /// safety limit rather than a budget: a pass now takes every
611    /// violation it can see, so reaching this means the conditions kept
612    /// moving and the answer is whatever the last pass reached.
613    IterationLimit,
614    /// A pass could not be solved or could not be achieved: the
615    /// conditions have exhausted the problem's degrees of freedom, and
616    /// no step holds them all. No budget helps.
617    DegreesOfFreedom,
618    /// The refinement ended further outside the bounds than the step it
619    /// started from, so the unrefined step was returned and no rows are
620    /// reported as constrained.
621    WorseThanPlain,
622}
623
624impl RefineStop {
625    /// A stable short name, for a caller reporting this across a
626    /// language boundary.
627    pub fn as_str(self) -> &'static str {
628        match self {
629            RefineStop::Settled => "settled",
630            RefineStop::IterationLimit => "iteration_limit",
631            RefineStop::DegreesOfFreedom => "degrees_of_freedom",
632            RefineStop::WorseThanPlain => "worse_than_plain",
633        }
634    }
635}
636
637/// How far a pass's correction may exceed the step it corrects before
638/// the pass is refused. The singular case a dense LU does not report
639/// comes back around `1e15`, so this only has to sit above the
640/// leverage a real pin can have — moving one coordinate onto its bound
641/// can legitimately move another by orders of magnitude more.
642const CORRECTION_SCALE_LIMIT: Number = 1e4;
643
644/// How much further outside the bounds the refinement may end than the
645/// step it started from before the unrefined step is returned instead.
646/// A refinement that leaves a coordinate this much further out has not
647/// repaired an active set, whatever it achieved on the rows it pinned.
648const WORSE_THAN_PLAIN_FACTOR: Number = 10.0;
649
650/// The refinement's release threshold: how far negative the step has to
651/// drive a bound multiplier before its bound is released, from the
652/// solve's own `bound_relax_factor`.
653///
654/// Never a caller's `bound_eps`. That is a primal margin, and a
655/// multiplier changing sign is not a primal event — reading one number
656/// for both is what let a `bound_eps` of `1e-2` stop every release on a
657/// model whose multipliers are of order `1e-3`.
658///
659/// The floor is `1e-9`, which is also what an unset or unreadable
660/// `bound_relax_factor` resolves to, since that is the floor by
661/// definition. Three callers reach this: `Solver::bound_context` off the
662/// recorded state, and the CLI and `SensSolve` off the options list through
663/// `options::release_floor_from_options` — all three in `pounce-sensitivity`,
664/// which depends on this crate, so they cannot be intra-doc links from here.
665/// One derivation, so they cannot drift on what the solve's own margin
666/// is.
667pub fn release_floor(bound_relax_factor: Number) -> Number {
668    bound_relax_factor.abs().max(1e-9)
669}
670
671/// Repair the active set the step implies, by pinning and releasing.
672///
673/// Returns the refined step, the compound rows it constrained, and why
674/// it stopped. This is upstream's fix-relax, both cases:
675///
676/// * a variable the step carries past a bound is pinned AT that bound,
677///   which activates it (their equation 17);
678/// * a bound multiplier the step drives negative is set to zero, which
679///   deactivates that bound and lets the variable move (equation 18).
680///
681/// Without the second, a variable sitting on a bound at the base point
682/// stays there however hard the perturbation pulls it off, because the
683/// step holds complementarity. Measured on a model whose bound wants to
684/// release, that is the difference between 0.0 and 1.667.
685///
686/// # One list per pass
687///
688/// A pass takes EVERY violation it can see — every coordinate outside
689/// a bound and every multiplier driven negative — and constrains all of
690/// them before re-solving, which is upstream's `BoundCheck` filling one
691/// `x_bound_violations_idx` and its caller's `while (bounds_violated)`
692/// re-solving over the lot. The loop then ends on its own, when the
693/// list comes back empty.
694///
695/// Taking only the worst one per pass, which this did until gh#732,
696/// needs as many passes as there are crossings. On a model with more
697/// crossings than passes `max_iter` stopped the loop rather than the
698/// violations doing it, so the budget picked the answer: on the CSTR of
699/// notebook 36 the pin count equalled the budget at every budget tried,
700/// and at 100 pins — half that problem's degrees of freedom — the
701/// refined step came back 8.6 times worse than the unrefined one.
702/// `max_iter` is a safety limit now, and a stop of
703/// [`RefineStop::IterationLimit`] is what says it fired.
704///
705/// Each pass adds its conditions and re-solves the augmented system
706/// carrying all of them, against the original factorization, so its
707/// correction is measured from the base step rather than the previous
708/// pass. Adding successive corrections counts the earlier ones twice.
709/// The Schur complement over those rows is what upstream's equations 19
710/// through 22 describe. The factorization is never rebuilt for a pin,
711/// which is what makes this cheaper than a re-solve; the Schur
712/// complement is rebuilt from scratch each pass, so a pass carrying `k`
713/// conditions costs one dense `k × k` solve and `k + 1` back-solves.
714/// Collecting the list makes `k` the number of crossings rather than
715/// the pass index, so the same repair costs passes instead of pins.
716///
717/// A release is not a Schur row here, unlike upstream, which puts the
718/// multiplier's row in the same list as the primal violations. It
719/// re-factors with that bound's `sigma` dropped, because an active
720/// bound's `sigma = z / s` grows as the solve converges and destroys
721/// the released system's information in the converged factor: computing
722/// a release from the held factor gets *worse* the better the solve
723/// converged, 2e-4 off at `tol = 1e-10` against 7e-9 at `1e-6`. What
724/// gh#732 fixes about a release is that the pins now survive it: their
725/// right-hand sides are re-measured against the re-solved base instead
726/// of the pin set being cleared, which is where that issue's budget
727/// table got its discontinuity. A pin batch that cannot be solved
728/// leaves the releases of its own pass standing for the same reason —
729/// a release repairs the active set on its own terms.
730///
731/// The release batch backs off the way the pin batch does, and for a
732/// sharper reason. A pin adds a condition, so an over-large batch shows
733/// up as an augmented system that cannot be solved. A release REMOVES
734/// one: every bound taken out is stiffness that is no longer holding
735/// its variable, and a batch that takes too many carries variables off
736/// bounds they were sitting on, with nothing left to pin them back.
737/// That has no failed solve to report it — on notebook 36's CSTR it was
738/// 56 releases where 41 were right, and the step came back worse than
739/// not refining at all. So a batch of more than one is kept only when
740/// the step it produces is no further outside the bounds than the one
741/// in hand.
742///
743/// `multipliers` carry their base values in the solve's own
744/// coordinates. They are converted here, once, with the backsolver's
745/// [`SensBacksolver::natural_units_factor`], so they agree with the `z`
746/// rows of `dx_plain` before either is used.
747///
748/// # Two margins
749///
750/// `eps` is the primal margin: how far outside a bound a coordinate has
751/// to end to count as having left it, which decides what a pass pins
752/// and what the two guards below compare overshoot against.
753/// `release_eps` is the dual one: how far negative the step has to
754/// drive a bound multiplier before the bound is released. They are two
755/// numbers because a caller who widens the primal margin is saying
756/// what counts as on the bound, and that says nothing about whether a
757/// multiplier at `-5e-3` has changed sign. With one number, a
758/// `bound_eps` of `1e-2` would stop every release on a model whose
759/// multipliers are of order `1e-3`, and return the wrong active set
760/// without saying so.
761///
762/// The two guards below stay on `eps`, since they compare primal
763/// overshoot and a caller who widened the primal margin has said that
764/// about overshoot too. The consequence is worth knowing before you
765/// widen it: both guards scale with `eps`, so a margin far above the
766/// model's own scale takes them out of the picture — at `eps = 10.0`
767/// the second reads `worst_over(dx) > 100.0`, and
768/// [`RefineStop::WorseThanPlain`] cannot be reached. A margin wide
769/// enough to pin nothing is also wide enough to accept any release
770/// batch it produces.
771///
772/// # Two guards, independent of the loop
773///
774/// A pass is refused when its correction is out of scale with the step
775/// it corrects, not only when a pinned row misses its target. Checking
776/// the pinned rows alone is what let gh#732's 100 pins each land within
777/// `1e-3` of where they were asked to go while the step as a whole came
778/// back unusable: hitting the pinned coordinates says nothing about
779/// what the correction did to the other 1300.
780///
781/// And the unrefined step is returned when the refinement ends further
782/// outside the bounds than it started, which costs nothing since
783/// `dx_plain` is already in scope. Repairing an active set that leaves
784/// the box further out than not repairing it at all has failed on its
785/// own terms.
786pub fn refine_step_onto_bounds<B>(
787    backsolver: &B,
788    dx_plain: &[Number],
789    x_curr: &[Number],
790    lo: &[Number],
791    hi: &[Number],
792    multipliers: &[BoundMultiplier],
793    rhs_plain: &[Number],
794    eps: Number,
795    release_eps: Number,
796    max_iter: usize,
797) -> Result<(Vec<Number>, Vec<usize>, RefineStop), String>
798where
799    B: crate::backsolver::SensBacksolver + Clone,
800{
801    use crate::sens_app::{SensApplication, SensOptions};
802
803    let n_full = dx_plain.len();
804    let mut dx = dx_plain.to_vec();
805    // Into the units the step is in, before either is read. `F` is
806    // indexed by compound row, the same space `BoundMultiplier::row`
807    // lives in.
808    let multipliers: Vec<BoundMultiplier> = match backsolver.natural_units_factor() {
809        None => multipliers
810            .iter()
811            .map(|m| BoundMultiplier {
812                row: m.row,
813                base: m.base,
814            })
815            .collect(),
816        Some(f) => multipliers
817            .iter()
818            .map(|m| BoundMultiplier {
819                row: m.row,
820                base: m.base * f[m.row],
821            })
822            .collect(),
823    };
824    let multipliers = &multipliers[..];
825    let bound_rows = backsolver.bound_rows();
826    let can_release = backsolver.supports_release() && rhs_plain.len() == n_full;
827
828    // How far outside its bounds the worst coordinate of a step sits.
829    let worst_over = |d: &[Number]| {
830        bound_violations(x_curr, d, lo, hi, eps, &[])
831            .first()
832            .map_or(0.0, |&(_, _, over)| over)
833    };
834
835    // Which multiplier rows the step drives negative, most negative
836    // first, ignoring any already out of the active set and any the
837    // factorization has already refused to release.
838    let releasable = |dx: &[Number], released: &[usize], refused: &[usize]| -> Vec<usize> {
839        if !can_release {
840            return Vec::new();
841        }
842        let mut v: Vec<(usize, Number)> = multipliers
843            .iter()
844            .filter(|m| !released.contains(&m.row) && !refused.contains(&m.row))
845            .filter(|m| bound_rows.is_some_and(|br| br.iter().any(|b| b.row == m.row)))
846            .map(|m| (m.row, m.base + dx[m.row]))
847            .filter(|&(_, v)| v < -release_eps)
848            .collect();
849        v.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
850        v.into_iter().map(|(r, _)| r).collect()
851    };
852
853    // The step under the given conditions, or `None` when the augmented
854    // system cannot deliver it. `Err` is reserved for a malformed
855    // condition set, which is a caller's bug rather than a refusal.
856    let solve_pins = |pins: &[(usize, Number)],
857                      released: &[usize],
858                      dx_base: &[Number]|
859     -> Result<Option<Vec<Number>>, String> {
860        if pins.is_empty() {
861            return Ok(Some(dx_base.to_vec()));
862        }
863        let rows: Vec<Index> = pins.iter().map(|&(r, _)| r as Index).collect();
864        // Measured from the base step, which moves whenever the
865        // released set does, so a pin outlives a release.
866        let rhs: Vec<Number> = pins
867            .iter()
868            .map(|&(r, bound)| (x_curr[r] + dx_base[r]) - bound)
869            .collect();
870        let signs = vec![1; rows.len()];
871        let mk = |r: Vec<Index>| {
872            IndexSchurData::from_parts(r, signs.clone()).map_err(|e| format!("{e:?}"))
873        };
874        let opts = SensOptions {
875            run_sens: true,
876            ..SensOptions::default()
877        };
878        // Against the released operator, not the converged one: once a
879        // bound is out of the active set, every later condition has to
880        // be solved in the system that reflects that.
881        let view = ReleasedView {
882            base: backsolver.clone(),
883            rows: released.to_vec(),
884            pinned: Vec::new(),
885        };
886        let mut pin_app = SensApplication::new(mk(rows.clone())?, view, opts);
887        let mut du = vec![0.0; rows.len()];
888        let mut corr = vec![0.0; n_full];
889        if !pin_app.run_sens_step(&mk(rows)?, &rhs, &mut du, &mut corr) {
890            // An exactly singular augmented system, where the two
891            // guards below catch the near-singular case.
892            return Ok(None);
893        }
894
895        // A healthy pass lands its conditions within a few parts per
896        // million, so this is not an accuracy check: it is for the
897        // singular case, where a dense LU returns a solution around
898        // 1e15 rather than reporting it.
899        let achieved = pins
900            .iter()
901            .zip(rhs.iter())
902            .all(|(&(r, _), &want)| (corr[r] + want).abs() <= 1e-3 * want.abs().max(1.0));
903        if !achieved {
904            return Ok(None);
905        }
906        // Achieving every pinned row says nothing about what the
907        // correction did to the rest of the vector (gh#732), so the
908        // correction's own size is checked too.
909        let inf = |v: &[Number]| v.iter().fold(0.0_f64, |a, b| a.max(b.abs()));
910        let scale = inf(dx_base).max(inf(&rhs)).max(1.0);
911        if inf(&corr) > CORRECTION_SCALE_LIMIT * scale {
912            return Ok(None);
913        }
914        Ok(Some(
915            dx_base
916                .iter()
917                .zip(corr.iter())
918                .map(|(b, c)| b + c)
919                .collect(),
920        ))
921    };
922
923    // The base step with `set` added to the released bounds, or `None`
924    // when the released system cannot be factored.
925    let apply_releases = |released: &[usize], set: &[usize]| -> Option<(Vec<usize>, Vec<Number>)> {
926        let mut trial = released.to_vec();
927        trial.extend_from_slice(set);
928        let mut base = vec![0.0; n_full];
929        if !backsolver.solve_released_step(&trial, rhs_plain, &mut base) {
930            return None;
931        }
932        // A released bound's multiplier is zero by construction; its
933        // own row of the re-solved step is a by-product of the
934        // complementarity row the factor still carries.
935        for &r in &trial {
936            if let Some(m) = multipliers.iter().find(|m| m.row == r) {
937                base[r] = -m.base;
938            }
939        }
940        Some((trial, base))
941    };
942
943    // (var-x row, the bound it is held at). The right-hand side is
944    // re-derived from the base step each pass rather than stored, so a
945    // release moves the pins with it instead of clearing them.
946    let mut pins: Vec<(usize, Number)> = Vec::new();
947    // Multiplier rows taken out of the active set. Unlike a pin these
948    // never become a Schur condition: they change the operator, so the
949    // step is re-solved against a factorization that does not carry
950    // their `sigma` at all.
951    let mut released: Vec<usize> = Vec::new();
952    // The step corrections are measured from. It moves whenever the
953    // released set does, since that is a different system.
954    let mut dx_base = dx_plain.to_vec();
955    // Bounds whose release the factorization would not deliver. Barred
956    // rather than retried: the same factorization would be asked for
957    // again every pass until the limit, and the limit is not what
958    // stopped it.
959    let mut refused_releases: Vec<usize> = Vec::new();
960    let mut stop = RefineStop::IterationLimit;
961
962    for _ in 0..max_iter {
963        let taken: Vec<usize> = pins.iter().map(|&(r, _)| r).collect();
964        let fresh_pins = bound_violations(x_curr, &dx, lo, hi, eps, &taken);
965        let fresh_releases = releasable(&dx, &released, &refused_releases);
966        if fresh_pins.is_empty() && fresh_releases.is_empty() {
967            // A bound whose release was refused is still one the step
968            // wants out of the active set. The loop has nothing left to
969            // try for it, which is not the same as having settled.
970            stop = if releasable(&dx, &released, &[]).is_empty() {
971                RefineStop::Settled
972            } else {
973                RefineStop::DegreesOfFreedom
974            };
975            break;
976        }
977
978        if !fresh_releases.is_empty() {
979            // A release is not a condition on the step, it is a
980            // different system: re-solve with those bounds' `sigma`
981            // gone, and measure the pins from the step that produces.
982            //
983            // The batch backs off the way the pin batch below does.
984            // Taking every negative multiplier at once can release more
985            // stiffness than the step wanted: on notebook 36's CSTR, 56
986            // releases where 41 were right carried five `v1` intervals
987            // off the bound they had been sitting on, with no degrees
988            // of freedom left to pin them back (gh#734 review). So the
989            // batch is kept only when the step it produces is no
990            // further outside the bounds than the one in hand, and
991            // otherwise the most negative multiplier goes alone and the
992            // next pass re-measures the rest under it.
993            let before = worst_over(&dx);
994            let mut sets: Vec<&[usize]> = vec![&fresh_releases[..]];
995            if fresh_releases.len() > 1 {
996                sets.push(&fresh_releases[..1]);
997            }
998            let mut taken: Option<(Vec<usize>, Vec<Number>, Vec<Number>)> = None;
999            for (k, set) in sets.iter().enumerate() {
1000                let Some((trial, base)) = apply_releases(&released, set) else {
1001                    continue;
1002                };
1003                let Some(step) = solve_pins(&pins, &trial, &base)? else {
1004                    continue;
1005                };
1006                // A single release is the smallest step the loop can
1007                // take toward a bound that has to leave the active set,
1008                // so it is taken whether or not it helps: refusing it
1009                // would leave a negative multiplier with nothing left
1010                // to do about it. The guard at the end still has the
1011                // last word on what comes back.
1012                let alone = k + 1 == sets.len();
1013                if alone || worst_over(&step) <= before.max(eps) {
1014                    taken = Some((trial, base, step));
1015                    break;
1016                }
1017            }
1018            match taken {
1019                Some((trial, base, step)) => {
1020                    released = trial;
1021                    dx_base = base;
1022                    dx = step;
1023                }
1024                None => {
1025                    // The released system could not be factored, or
1026                    // could not carry the pins already placed.
1027                    refused_releases.extend_from_slice(&fresh_releases);
1028                    if fresh_pins.is_empty() {
1029                        stop = RefineStop::DegreesOfFreedom;
1030                        break;
1031                    }
1032                }
1033            }
1034            if fresh_pins.is_empty() {
1035                // The release phase already produced this pass's step.
1036                continue;
1037            }
1038        }
1039
1040        // What the pin batch can undo, snapshotted BELOW the release
1041        // phase so a release is not among it. Both halves of that
1042        // matter and they are separable (gh#734 review bisected them):
1043        // keeping `released` is what leaves the bounds out of the
1044        // active set at all, and snapshotting `dx` here rather than
1045        // above is what leaves the STEP the release produced. Roll back
1046        // only the first and the rows come back while the answer stays
1047        // the plain step's; roll back both and a sound release is
1048        // discarded because the pins that came with it did not fit. A
1049        // release repairs the active set on its own terms.
1050        let keep_pins = pins.clone();
1051        let keep_dx = dx.clone();
1052        pins.extend(fresh_pins.iter().map(|&(i, bound, _)| (i, bound)));
1053        let mut next = solve_pins(&pins, &released, &dx_base)?;
1054        if next.is_none() && fresh_pins.len() > 1 {
1055            // The batch asked for more than the remaining degrees of
1056            // freedom hold. Keep the worst of the new crossings and let
1057            // the next pass re-measure the rest under it, which is what
1058            // the one-at-a-time loop would have done.
1059            pins.truncate(keep_pins.len());
1060            pins.push((fresh_pins[0].0, fresh_pins[0].1));
1061            next = solve_pins(&pins, &released, &dx_base)?;
1062        }
1063        match next {
1064            Some(step) => dx = step,
1065            None => {
1066                pins = keep_pins;
1067                dx = keep_dx;
1068                stop = RefineStop::DegreesOfFreedom;
1069                break;
1070            }
1071        }
1072    }
1073
1074    // The loop can also run out of passes on the one that settled it,
1075    // which is not the limit firing. And what is left can be a bound
1076    // the factorization refused to release, which no budget reaches.
1077    if stop == RefineStop::IterationLimit {
1078        let taken: Vec<usize> = pins.iter().map(|&(r, _)| r).collect();
1079        let pins_left = !bound_violations(x_curr, &dx, lo, hi, eps, &taken).is_empty();
1080        let rel_left = releasable(&dx, &released, &[]);
1081        if !pins_left && rel_left.is_empty() {
1082            stop = RefineStop::Settled;
1083        } else if !pins_left && rel_left.iter().all(|r| refused_releases.contains(r)) {
1084            stop = RefineStop::DegreesOfFreedom;
1085        }
1086    }
1087
1088    // Whatever stopped it, a refinement that ends further outside the
1089    // bounds than the step it started from has failed on its own terms.
1090    let plain_worst = worst_over(dx_plain);
1091    if worst_over(&dx) > WORSE_THAN_PLAIN_FACTOR * plain_worst.max(eps) {
1092        return Ok((dx_plain.to_vec(), Vec::new(), RefineStop::WorseThanPlain));
1093    }
1094
1095    let mut out = released.clone();
1096    out.extend(pins.into_iter().map(|(r, _)| r));
1097    Ok((dx, out, stop))
1098}
1099
1100/// The converged backsolver with a set of bounds out of the active set,
1101/// so the pin machinery can run against the released system without
1102/// knowing that is what it is doing.
1103#[derive(Clone)]
1104struct ReleasedView<B: crate::backsolver::SensBacksolver + Clone> {
1105    base: B,
1106    rows: Vec<usize>,
1107    /// Primal rows whose diagonal the *operator* stiffens, empty for
1108    /// the ordinary released view. See
1109    /// [`crate::backsolver::SensBacksolver::solve_released_pinned`]:
1110    /// this is not the pin, it is the regularization that lets the pin
1111    /// be applied at all (gh#930).
1112    pinned: Vec<usize>,
1113}
1114
1115impl<B: crate::backsolver::SensBacksolver + Clone> crate::backsolver::SensBacksolver
1116    for ReleasedView<B>
1117{
1118    fn dim(&self) -> usize {
1119        self.base.dim()
1120    }
1121    fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
1122        if !self.pinned.is_empty() {
1123            return self
1124                .base
1125                .solve_released_pinned(&self.rows, &self.pinned, rhs, lhs);
1126        }
1127        // Nothing released is the converged system, so ask for it
1128        // directly: routing an empty set through `solve_released` asks
1129        // a backsolver that cannot release for something it does not
1130        // need to do, and the ones that can already short-circuit it.
1131        if self.rows.is_empty() {
1132            return self.base.solve(rhs, lhs);
1133        }
1134        self.base.solve_released(&self.rows, rhs, lhs)
1135    }
1136    fn natural_units_factor(&self) -> Option<&[Number]> {
1137        self.base.natural_units_factor()
1138    }
1139    fn bound_rows(&self) -> Option<&[crate::backsolver::BoundRow]> {
1140        self.base.bound_rows()
1141    }
1142    fn supports_release(&self) -> bool {
1143        self.base.supports_release()
1144    }
1145    fn solve_released(&self, released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
1146        self.base.solve_released(released, rhs, lhs)
1147    }
1148    fn solve_released_step(&self, released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
1149        self.base.solve_released_step(released, rhs, lhs)
1150    }
1151}
1152
1153/// A bound this far out is the reader's absent-bound sentinel rather
1154/// than a bound, and a step cannot cross it.
1155const NO_BOUND_LO: Number = -1e19;
1156/// Mirror of [`NO_BOUND_LO`].
1157const NO_BOUND_HI: Number = 1e19;
1158/// A segment shorter than this has not advanced the path, so the
1159/// rows changed at its start stay barred from changing back.
1160const PATH_MIN_SEGMENT: Number = 1e-12;
1161
1162/// How many times the walk may re-run after finding its own answer
1163/// outside the box.
1164///
1165/// This is not a tuned number. A pass that adds nothing stops the
1166/// loop, the only rows it can add are entries of the base-activity
1167/// table, and a row already watched is never added twice -- so the
1168/// loop cannot run more times than that table has entries, and
1169/// passing the table's length as the budget makes the exhausted arm
1170/// unreachable by construction rather than unreached by luck. That
1171/// matters because the exhausted arm returns an error, and an error
1172/// on a correct model would be a regression the corpus could not see.
1173///
1174/// Measured for the record, by capping this to `.min(1)` and
1175/// re-running: a budget of **1** clears every Rust test in
1176/// `pounce-sens-core`, `pounce-sensitivity` and `pounce-py`, both
1177/// gh#928 files included -- 0 failures. The bound below is therefore
1178/// slack over that population; it is here so that "the budget ran
1179/// out" is a statement about the model rather than about this
1180/// constant.
1181fn path_box_repair_budget(base_active_rows: usize) -> usize {
1182    base_active_rows
1183}
1184
1185/// One breakpoint the path stopped at.
1186#[derive(Clone, Copy, Debug, PartialEq)]
1187pub struct PathSegment {
1188    /// Fraction of the perturbation applied when this segment ended,
1189    /// measured from the base point.
1190    pub at: Number,
1191    /// Var-x row of the variable whose bound status changed, whatever
1192    /// the kind of change. A release is detected on the bound's
1193    /// multiplier row, but it is recorded here by the variable it
1194    /// frees, so a caller never needs the multiplier layout to read
1195    /// the record.
1196    pub var_row: usize,
1197    /// `true` when the bound involved is the variable's lower bound.
1198    pub lower: bool,
1199    /// `true` when the variable reached the bound and is held there
1200    /// from this fraction on, `false` when it left it: either a bound
1201    /// active at the base whose multiplier reached zero, or a hold
1202    /// this path added earlier whose multiplier crossed zero.
1203    ///
1204    /// A weakly active bound can be recorded `true` at a fraction of
1205    /// essentially zero, and that does not contradict the variable
1206    /// having been on it at the base point: what the working set
1207    /// gained there is the HOLD. Undecided, the bound sat in the
1208    /// factorization as an order-one penalty that does not enforce it
1209    /// (gh#852).
1210    pub pinned: bool,
1211}
1212
1213/// A variable the path holds at a bound it reached, with the
1214/// accumulated multiplier on its Schur row. The multiplier starts at
1215/// zero where the hold is added, exactly the crossing, takes a sign on
1216/// the segment after, and the hold drops where it crosses zero again,
1217/// which is the "drop" half of add-and-drop.
1218#[derive(Clone, Copy, Debug)]
1219struct PathHold {
1220    /// Var-x row held.
1221    row: usize,
1222    /// `true` when the bound held is the variable's lower bound. Only
1223    /// the record reads this: the drop test does not care which side
1224    /// the hold is on.
1225    lower: bool,
1226    /// Accumulated Schur-row multiplier, in whatever sign convention
1227    /// the augmented system uses: the drop test only asks when it
1228    /// crosses zero, so the convention never needs to be named.
1229    mult: Number,
1230}
1231
1232/// Apply the perturbation a little at a time, stopping wherever the
1233/// active set changes.
1234///
1235/// [`refine_step_onto_bounds`] decides every condition at the base
1236/// point. This advances instead: it takes the fraction of the
1237/// perturbation that reaches the first breakpoint, applies that one
1238/// change, and continues from there with the remainder under the new
1239/// active set. The result is piecewise linear in the parameter, which
1240/// is the exact solution for a QP, whose solution is piecewise affine
1241/// in the parameter. For an NLP it stays a predictor, because nothing
1242/// is re-linearized between breakpoints.
1243///
1244/// Three kinds of breakpoint end a segment, all ratio tests on
1245/// quantities the step already carries. A variable strictly inside its
1246/// bounds reaches one, and is held there. A bound active at the base
1247/// has its multiplier reach zero, and the variable leaves it. A hold
1248/// this path added earlier has its multiplier cross zero, and the
1249/// variable leaves that bound too: the direction changes at every
1250/// breakpoint, so a bound reached under one direction may stop binding
1251/// under a later one.
1252///
1253/// Releasing a base-active bound needs no right-hand-side shift,
1254/// unlike the base-point refinement. The path stops exactly where the
1255/// multiplier reaches zero, so there is nothing left to drive to zero.
1256/// Dropping a hold needs no re-factorization at all, since the hold is
1257/// a Schur row rather than a term in the held factor.
1258///
1259/// `weak_rows` names the bound-multiplier rows the activity
1260/// classifier could not certify as strongly active. Those rows sit in
1261/// the factorization with an order-one sigma that bends the direction
1262/// without enforcing the bound, so the walk is allowed to reach one
1263/// and hold it, releasing the row as it does. Every other base-active
1264/// bound stays unreachable: its sigma is order `1/mu`, its variable
1265/// cannot move off the bound, and a Schur hold there would enforce
1266/// the same bound twice through a near-singular complement.
1267///
1268/// Returns the accumulated step and the breakpoints crossed. When
1269/// `max_iter` segments are used before the target is reached, the
1270/// remainder is taken in one step under the active set reached, since
1271/// stopping short would answer a perturbation the caller did not ask
1272/// for. A returned segment count equal to `max_iter` is what says that
1273/// happened.
1274#[allow(clippy::too_many_arguments)]
1275pub fn step_along_path<B>(
1276    backsolver: &B,
1277    rhs_plain: &[Number],
1278    x_curr: &[Number],
1279    lo: &[Number],
1280    hi: &[Number],
1281    multipliers: &[BoundMultiplier],
1282    max_iter: usize,
1283    forced_active: &[usize],
1284    initial_holds: &[(usize, bool)],
1285    weak_rows: &[usize],
1286    eps: Number,
1287) -> Result<(Vec<Number>, Vec<PathSegment>), String>
1288where
1289    B: crate::backsolver::SensBacksolver + Clone,
1290{
1291    let n_full = backsolver.dim();
1292    // The PRIMAL PREFIX, not the `x` block. `bound_context` hands over
1293    // a box spanning `x` then `s`, which are contiguous in the
1294    // compound vector, because a limit written as a constraint row is
1295    // a bound on the slack and has to be watched like any other
1296    // (gh#928). Everything below indexes the box, the base point and
1297    // the step by the same primal KKT row, so one length covers all
1298    // three and the walk needs no second index space.
1299    let n_p = x_curr.len().min(lo.len()).min(hi.len());
1300    if rhs_plain.len() != n_full {
1301        return Err("step_along_path: rhs length is not the KKT dimension".into());
1302    }
1303    // The same conversion the refinement makes, for the same reason:
1304    // these arrive in the solve's coordinates and get compared against
1305    // the z rows of a step, which are in the model's.
1306    let mult_nat: Vec<BoundMultiplier> = match backsolver.natural_units_factor() {
1307        None => multipliers
1308            .iter()
1309            .map(|m| BoundMultiplier {
1310                row: m.row,
1311                base: m.base,
1312            })
1313            .collect(),
1314        Some(f) => multipliers
1315            .iter()
1316            .map(|m| BoundMultiplier {
1317                row: m.row,
1318                base: m.base * f[m.row],
1319            })
1320            .collect(),
1321    };
1322    let bound_rows: Option<Vec<crate::backsolver::BoundRow>> =
1323        backsolver.bound_rows().map(|b| b.to_vec());
1324    let can_release = backsolver.supports_release();
1325
1326    // Which bounds the factorization enforces, decided once. Active
1327    // means the multiplier dominates the slack. A converged interior
1328    // point never sits ON a bound: an active bound's slack is order mu
1329    // over the multiplier, so testing slack against `eps` calls every
1330    // active bound inactive and the path never releases anything.
1331    // Complementarity splits the two sides cleanly, z of order one
1332    // against slack of order mu on the active side and the reverse on
1333    // the inactive, which is the same split the activity classifier
1334    // draws.
1335    //
1336    // The split is evaluated at the BASE point, which is what makes
1337    // deciding it here, before the loop, correct rather than a cache:
1338    // activity of a multiplier row is a property of the factorization,
1339    // whose sigma for this bound was frozen at the base, and a bound
1340    // inactive there is represented by a Schur-row hold if the path
1341    // reaches it, never by its multiplier row. Testing accumulated
1342    // values instead let a near-bound inactive multiplier drift past
1343    // its shrinking slack mid-path and "release" a bound that was
1344    // never held, putting a departure in the record for a variable
1345    // that was not on that bound.
1346    //
1347    // What stays live at every consumer is the released list: a
1348    // base-active bound whose row has been released is no longer in
1349    // the factorization, from that fraction on.
1350    let mut base_active_row: Vec<[Option<usize>; 2]> = vec![[None, None]; n_p];
1351    if let Some(rows) = bound_rows.as_ref() {
1352        for br in rows {
1353            // `var_row` is a primal KKT row, so this is a range check
1354            // against the box the caller supplied, not a block filter.
1355            // A caller that hands over an `x`-only box still gets the
1356            // old behaviour: its constraint-row bounds fall out here.
1357            if br.var_row >= n_p {
1358                continue;
1359            }
1360            let slack_base = if br.lower {
1361                x_curr[br.var_row] - lo[br.var_row]
1362            } else {
1363                hi[br.var_row] - x_curr[br.var_row]
1364            };
1365            if !slack_base.is_finite() {
1366                continue;
1367            }
1368            if forced_active.contains(&br.row)
1369                || mult_nat
1370                    .iter()
1371                    .any(|m| m.row == br.row && m.base > slack_base)
1372            {
1373                let side = if br.lower { 0 } else { 1 };
1374                base_active_row[br.var_row][side] = Some(br.row);
1375            }
1376        }
1377    }
1378    let base_active_rows: Vec<usize> = base_active_row
1379        .iter()
1380        .flatten()
1381        .filter_map(|slot| *slot)
1382        .collect();
1383
1384    // The walk owes its caller a point inside the box, and the reach
1385    // scan above is the only thing that keeps that promise. A bound the
1386    // factorization enforces only SOFTLY -- sigma of order one rather
1387    // than order 1/mu -- is skipped by that scan as though it were
1388    // held, and then holds nothing, so the direction carries the
1389    // variable straight through it and no breakpoint is recorded
1390    // (gh#852). `weak_rows` is the caller's list of exactly those
1391    // bounds, and it is only ever as good as the activity classifier
1392    // that built it: where the Hessian diagonal falls below the
1393    // identification floor every bound classifies UNIDENTIFIED,
1394    // `weakly_active_bounds` returns an empty list, and the scan skips
1395    // a bound nothing is holding. That is not an exotic model -- it is
1396    // every LP, and every model whose cost is linear in the coordinate
1397    // that reaches the bound.
1398    //
1399    // So the walk does not rely on being told. It runs, compares its
1400    // own answer against the box, and treats a base-active bound the
1401    // answer CROSSED as proof that the factorization did not enforce
1402    // it -- measured on the result rather than inferred from a
1403    // curvature that, in this regime, is precisely what cannot be
1404    // measured. Such a bound joins the watch list and the walk repeats
1405    // with a breakpoint available there.
1406    //
1407    // Seeded from `weak_rows` rather than replacing it. Measured: the
1408    // box check below rediscovers most of what the seed supplies, but
1409    // not all -- a stale sigma that damps a coordinate at a later
1410    // breakpoint is a RATE error, and the coordinate never leaves its
1411    // box, so there is nothing for a box check to observe. The seed
1412    // catches what the classifier can name and the check catches what
1413    // it cannot.
1414    //
1415    // The watch list only grows and is bounded by the number of bound
1416    // rows, so this terminates; the cap is a budget on factorizations,
1417    // not the termination argument.
1418    //
1419    // A re-walk that FAILS is reported, not swallowed. The tempting
1420    // thing is to keep the answer already in hand, but that answer is
1421    // out of the box -- being out of the box is why there was a second
1422    // walk at all -- and handing it back silently is precisely the
1423    // defect this loop exists to remove. A caller told the repair
1424    // failed can re-solve; a caller handed a point past a generator's
1425    // rating with no breakpoint and no error cannot even know to ask.
1426    //
1427    // A pass that cannot GROW the list is a different matter and keeps
1428    // its answer. There the violated coordinate has no base-active
1429    // bound row on the side it left, so the reach scan was already
1430    // watching that bound and the walk stopped where it could: the
1431    // overshoot is some other condition -- an exhausted `max_iter`,
1432    // most likely -- and not the one this loop claims to fix. Erroring
1433    // there would change behaviour on a condition the fix has no
1434    // evidence about.
1435    let setup = WalkSetup {
1436        rhs_plain,
1437        x_curr,
1438        lo,
1439        hi,
1440        n_p,
1441        n_full,
1442        max_iter,
1443        mult_nat,
1444        bound_rows,
1445        can_release,
1446        base_active_row,
1447        base_active_rows,
1448        initial_holds,
1449    };
1450    let mut watch: Vec<usize> = weak_rows.to_vec();
1451    let budget = path_box_repair_budget(setup.base_active_rows.len());
1452    let mut best = walk_once(backsolver, &setup, &watch)?;
1453    for _ in 0..budget {
1454        let mut grew = false;
1455        for (i, _, _) in bound_violations(
1456            setup.x_curr,
1457            &best.0[..setup.n_p],
1458            setup.lo,
1459            setup.hi,
1460            eps,
1461            &[],
1462        ) {
1463            // Which side it left, read off the answer rather than the
1464            // base point: the base point is ON the bound here, so its
1465            // slack cannot say which way the walk went.
1466            let side = usize::from(setup.x_curr[i] + best.0[i] > setup.hi[i]);
1467            if let Some(r) = setup.base_active_row[i][side]
1468                && !watch.contains(&r)
1469            {
1470                watch.push(r);
1471                grew = true;
1472            }
1473        }
1474        if !grew {
1475            break;
1476        }
1477        best = walk_once(backsolver, &setup, &watch).map_err(|e| {
1478            format!(
1479                "step_along_path: the walk left the box and the repair failed \
1480                 (watching {watch:?}): {e}"
1481            )
1482        })?;
1483    }
1484
1485    // The walk owes its caller a point inside the box, so an answer
1486    // still outside one here is wrong however it got there -- the
1487    // repair ran out of budget, or the crossing was at a bound the
1488    // base-point split did not call active and so there was no row to
1489    // add. Returning it is exactly gh#928's own failure mode, a
1490    // violation with nothing in the record naming it, so say so
1491    // instead. The budget arm is unreachable by construction (see
1492    // `path_box_repair_budget`); the no-row-to-add arm is reachable in
1493    // principle and is not reached by any fixture in the corpus. That
1494    // is the reason to report rather than to trust them.
1495    //
1496    // Except when the caller capped the walk. `max_iter` is a cap on
1497    // segments, and a walk that spent it stopped early BY REQUEST:
1498    // `max_iter = 0` is the plain linear step, which is outside the
1499    // box whenever the bound binds, and was a legal thing to ask for
1500    // before this repair existed. Measured, not reasoned: on the
1501    // gh#928 LP reproducer `max_iter = 0` returns a point 1e-2 past
1502    // the bound, and turning that into an error would blame the
1503    // repair for the caller's own budget. A truncated walk keeps the
1504    // old contract; only an untruncated one makes the promise.
1505    if best.1.len() >= max_iter {
1506        return Ok(best);
1507    }
1508    let left = bound_violations(
1509        setup.x_curr,
1510        &best.0[..setup.n_p],
1511        setup.lo,
1512        setup.hi,
1513        eps,
1514        &[],
1515    );
1516    if let Some((i, bnd, past)) = left.first() {
1517        // `i` is a primal KKT row, so it names a variable only while
1518        // it is inside the `x` block; past that it is a constraint's
1519        // own slack. Saying which costs nothing and saves the reader
1520        // from reading a slack index as a variable index (gh#450).
1521        return Err(format!(
1522            "step_along_path: the walk ended outside primal row {i}'s bound \
1523             {bnd} by {past:e} and the repair could not reach it \
1524             (watched {} rows over at most {budget} passes, \
1525             {} segments of a {max_iter} cap). Rows below the `x` block's \
1526             length are variables; at or above it they are constraint \
1527             slacks, so read the row against `block_dims()`.",
1528            watch.len(),
1529            best.1.len()
1530        ));
1531    }
1532    Ok(best)
1533}
1534
1535/// Everything [`walk_once`] reads that a repair pass does not change:
1536/// the base point, its box, and the base-activity split decided once
1537/// above. Bundled rather than passed loose because the walk runs more
1538/// than once and the argument list is the part that would drift.
1539struct WalkSetup<'a> {
1540    rhs_plain: &'a [Number],
1541    x_curr: &'a [Number],
1542    lo: &'a [Number],
1543    hi: &'a [Number],
1544    /// Length of the primal prefix (`x` then `s`) the box covers.
1545    n_p: usize,
1546    n_full: usize,
1547    max_iter: usize,
1548    mult_nat: Vec<BoundMultiplier>,
1549    bound_rows: Option<Vec<crate::backsolver::BoundRow>>,
1550    can_release: bool,
1551    base_active_row: Vec<[Option<usize>; 2]>,
1552    base_active_rows: Vec<usize>,
1553    initial_holds: &'a [(usize, bool)],
1554}
1555
1556/// One pass of the walk, under the weak-row set it is given.
1557///
1558/// Split out of [`step_along_path`] so the box check there can run it
1559/// again with a bound the first pass proved unheld. Every pass starts
1560/// from the base point: the accumulated step, the holds and the
1561/// released list are all local, so a repair pass is a fresh walk and
1562/// not a continuation of the one that missed the crossing.
1563fn walk_once<B>(
1564    backsolver: &B,
1565    su: &WalkSetup<'_>,
1566    weak_rows: &[usize],
1567) -> Result<(Vec<Number>, Vec<PathSegment>), String>
1568where
1569    B: crate::backsolver::SensBacksolver + Clone,
1570{
1571    let rhs_plain = su.rhs_plain;
1572    let x_curr = su.x_curr;
1573    let lo = su.lo;
1574    let hi = su.hi;
1575    let n_p = su.n_p;
1576    let n_full = su.n_full;
1577    let max_iter = su.max_iter;
1578    let mult_nat = &su.mult_nat;
1579    let bound_rows = &su.bound_rows;
1580    let can_release = su.can_release;
1581    let base_active_row = &su.base_active_row;
1582    let base_active_rows = &su.base_active_rows;
1583    let initial_holds = su.initial_holds;
1584
1585    let mut acc = vec![0.0; n_full];
1586    let mut t = 0.0_f64;
1587    // Seeded state from the directional-derivative decision at a
1588    // degenerate base point. A weakly active row the direction holds
1589    // arrives released, since its order-one sigma is wrong once the
1590    // direction later changes, and pinned through a Schur hold with
1591    // zero accumulated multiplier, exactly as a hold added at fraction
1592    // zero would, so the drop test can end it later like any other. A
1593    // weakly active row the direction leaves goes into the
1594    // base-activity table below instead, so the release scan frees it
1595    // at the fraction where its multiplier actually reaches zero:
1596    // essentially zero at an exact kink, and partway along the step
1597    // when the held solve sits inside the ambiguous band, where the
1598    // bound is genuinely active for the first stretch. Deciding those
1599    // rows at fraction zero released them a sixth of a step early on
1600    // the CSTR held at 75% of the breakpoint fraction, and overshot
1601    // tenfold against the walk's own release. A leaver is not a
1602    // one-way door, though: `weak_rows` keeps it reachable, so a
1603    // direction that turns out to press into it is a breakpoint and
1604    // the walk takes the bound back there (gh#852).
1605    let mut holds: Vec<PathHold> = initial_holds
1606        .iter()
1607        .map(|&(row, lower)| PathHold {
1608            row,
1609            lower,
1610            mult: 0.0,
1611        })
1612        .collect();
1613    let mut released: Vec<usize> = initial_holds
1614        .iter()
1615        .filter_map(|&(var_row, lower)| {
1616            bound_rows.as_ref().and_then(|rows| {
1617                rows.iter()
1618                    .find(|b| b.var_row == var_row && b.lower == lower)
1619                    .map(|b| b.row)
1620            })
1621        })
1622        .collect();
1623    let mut segments: Vec<PathSegment> = Vec::new();
1624    // Rows already changed at the fraction the path currently ends at.
1625    // A zero-length segment is where cycling comes from, so a row that
1626    // just changed cannot change back at the same fraction. The list
1627    // clears as soon as the path advances: barring a row any longer
1628    // makes it miss real breakpoints in the following segment,
1629    // which showed up as a released variable whose next bound crossing
1630    // went unrecorded.
1631    let mut changed_here: Vec<usize> = Vec::new();
1632    let mut last_beta = 1.0_f64;
1633
1634    /// What the earliest breakpoint found so far does.
1635    #[derive(Clone, Copy, PartialEq)]
1636    enum Event {
1637        ReachLower,
1638        ReachUpper,
1639        ReleaseBase,
1640        DropHold,
1641    }
1642
1643    for _ in 0..max_iter {
1644        if last_beta > PATH_MIN_SEGMENT {
1645            changed_here.clear();
1646        }
1647        let held: Vec<usize> = holds.iter().map(|h| h.row).collect();
1648        let (d, du) = path_direction(backsolver, rhs_plain, &released, &held)?;
1649        let remaining = 1.0 - t;
1650        if remaining <= 0.0 {
1651            break;
1652        }
1653
1654        let mut best: Option<(Number, usize, Event)> = None;
1655        let mut offer = |beta: Number, row: usize, ev: Event| {
1656            if !beta.is_finite() || beta < 0.0 || beta > remaining {
1657                return;
1658            }
1659            match best {
1660                Some((b, _, _)) if b <= beta => {}
1661                _ => best = Some((beta, row, ev)),
1662            }
1663        };
1664
1665        // A free variable reaching a bound, or a weakly active one
1666        // reaching it again. A bound the held factorization actually
1667        // enforces is not reachable this way: its variable sits
1668        // essentially on it already, and holding it AGAIN through a
1669        // Schur row would enforce the same bound twice. Such a bound
1670        // leaves the active set only through its own multiplier's
1671        // release below. "Actually enforces" is the distinction the
1672        // `factor_holds` comment below draws, and it is narrower than
1673        // "active at the base".
1674        for i in 0..n_p {
1675            if holds.iter().any(|h| h.row == i) || changed_here.contains(&i) {
1676                continue;
1677            }
1678            // Base activity was decided once, at the table above; only
1679            // the released exclusion is live, since a released bound
1680            // left the factorization mid-path.
1681            //
1682            // A weakly active row is the exception, and gh#852 is what
1683            // it costs to leave it out. Its sigma is order ONE, not
1684            // order 1/mu: the factorization carries the bound as a
1685            // finite penalty that bends the direction and does not
1686            // enforce anything, so a direction that drives the
1687            // variable outside its bound does exactly that, with no
1688            // breakpoint to stop it. Excluding it here left the
1689            // coupled kink's walk with nothing to report and the
1690            // crossing coordinate outside its box, repaired downstream
1691            // only by a clamp, which moves that coordinate and leaves
1692            // every neighbour at the one-sided value.
1693            let factor_holds = |lower_side: bool| -> bool {
1694                let side = if lower_side { 0 } else { 1 };
1695                base_active_row[i][side]
1696                    .is_some_and(|r| !released.contains(&r) && !weak_rows.contains(&r))
1697            };
1698            let v = x_curr[i] + acc[i];
1699            if d[i] < 0.0 && lo[i] > NO_BOUND_LO && !factor_holds(true) {
1700                offer((lo[i] - v) / d[i], i, Event::ReachLower);
1701            }
1702            if d[i] > 0.0 && hi[i] < NO_BOUND_HI && !factor_holds(false) {
1703                offer((hi[i] - v) / d[i], i, Event::ReachUpper);
1704            }
1705        }
1706        // A bound active at the base whose multiplier reaches zero.
1707        // Base activity comes from the table above; which rows have
1708        // since been released stays a live check.
1709        if can_release {
1710            for m in mult_nat {
1711                if released.contains(&m.row)
1712                    || changed_here.contains(&m.row)
1713                    || !base_active_rows.contains(&m.row)
1714                {
1715                    continue;
1716                }
1717                let z_curr = m.base + acc[m.row];
1718                if d[m.row] < 0.0 {
1719                    offer(-z_curr / d[m.row], m.row, Event::ReleaseBase);
1720                }
1721            }
1722        }
1723        // A hold this path added whose multiplier crosses zero. The
1724        // rate is the row's `du` under the current direction. Which
1725        // sign is the valid side depends on conventions three layers
1726        // deep, so the test does not choose one: the multiplier took
1727        // some sign on the segment after the hold was added, and
1728        // crossing zero from that side is what ends the hold's
1729        // validity. At creation the multiplier is exactly zero and the
1730        // product below is zero, so a fresh hold cannot drop before it
1731        // has accumulated a sign.
1732        for (k, h) in holds.iter().enumerate() {
1733            if changed_here.contains(&h.row) {
1734                continue;
1735            }
1736            let rate = du[k];
1737            if h.mult * rate < 0.0 {
1738                offer(-h.mult / rate, h.row, Event::DropHold);
1739            }
1740        }
1741
1742        let Some((beta, row, ev)) = best else {
1743            // Nothing changes before the target, so the rest is one step.
1744            for (a, dv) in acc.iter_mut().zip(d.iter()) {
1745                *a += remaining * dv;
1746            }
1747            t = 1.0;
1748            break;
1749        };
1750
1751        for (a, dv) in acc.iter_mut().zip(d.iter()) {
1752            *a += beta * dv;
1753        }
1754        for (k, h) in holds.iter_mut().enumerate() {
1755            h.mult += beta * du[k];
1756        }
1757        last_beta = beta;
1758        t += beta;
1759        changed_here.push(row);
1760        let (var_row, lower) = match ev {
1761            Event::ReachLower | Event::ReachUpper => {
1762                let lower = ev == Event::ReachLower;
1763                // A weakly active bound the walk reaches leaves the
1764                // factorization at the same fraction, which is the
1765                // treatment `initial_holds` already gets and for the
1766                // same reason: from here on the Schur hold is what
1767                // enforces the bound, and the row's order-one sigma is
1768                // a second, softer copy of it built at a base point
1769                // whose direction no longer applies. While the hold
1770                // stands the two are indistinguishable -- the hold
1771                // takes the coordinate's movement to zero and sigma
1772                // multiplies exactly that -- so what the release is
1773                // for is the fraction AFTER the hold drops, where the
1774                // coordinate moves again and a stale order-one sigma
1775                // damps it. The base-activity table is not the test
1776                // here: sigma is in the factor for every bound, and a
1777                // weak row lands on either side of that table's
1778                // multiplier-against-slack comparison.
1779                let reached_row = bound_rows.as_ref().and_then(|rows| {
1780                    rows.iter()
1781                        .find(|b| b.var_row == row && b.lower == lower)
1782                        .map(|b| b.row)
1783                });
1784                if can_release
1785                    && let Some(r) = reached_row
1786                    && weak_rows.contains(&r)
1787                    && !released.contains(&r)
1788                {
1789                    released.push(r);
1790                    changed_here.push(r);
1791                }
1792                holds.push(PathHold {
1793                    row,
1794                    lower,
1795                    mult: 0.0,
1796                });
1797                (row, lower)
1798            }
1799            Event::ReleaseBase => {
1800                // The release scan only offers rows it found bound
1801                // metadata for, so this lookup cannot miss.
1802                let Some(br) = bound_rows
1803                    .as_ref()
1804                    .and_then(|rows| rows.iter().find(|b| b.row == row))
1805                else {
1806                    return Err("step_along_path: released a row with no bound metadata".into());
1807                };
1808                // Bar the released variable's own row too: the reach
1809                // scan works in var rows while the release recorded the
1810                // multiplier row, and without this the variable can be
1811                // re-held at the same fraction it was just released.
1812                changed_here.push(br.var_row);
1813                released.push(row);
1814                (br.var_row, br.lower)
1815            }
1816            Event::DropHold => {
1817                // The drop event came from iterating the holds, so the
1818                // hold is present.
1819                let Some(h) = holds.iter().find(|h| h.row == row).copied() else {
1820                    return Err("step_along_path: dropped a hold that does not exist".into());
1821                };
1822                holds.retain(|h| h.row != row);
1823                (row, h.lower)
1824            }
1825        };
1826        segments.push(PathSegment {
1827            at: t,
1828            var_row,
1829            lower,
1830            pinned: matches!(ev, Event::ReachLower | Event::ReachUpper),
1831        });
1832    }
1833
1834    // The cap bound before the target was reached, so take what is left
1835    // under the active set reached.
1836    if t < 1.0 {
1837        let held: Vec<usize> = holds.iter().map(|h| h.row).collect();
1838        let (d, _) = path_direction(backsolver, rhs_plain, &released, &held)?;
1839        for (a, dv) in acc.iter_mut().zip(d.iter()) {
1840            *a += (1.0 - t) * dv;
1841        }
1842    }
1843    Ok((acc, segments))
1844}
1845
1846/// The step for the whole perturbation under the active set the path
1847/// has reached: released bounds out of the operator with their
1848/// multipliers constrained to stay at zero, and held variables kept
1849/// where they are.
1850///
1851/// The multiplier constraint is not optional. The re-factored released
1852/// operator drops the bound's diagonal term, but the factor's
1853/// complementarity row for that bound still couples the direction
1854/// through the base slack and multiplier it was built from, and
1855/// without the constraint the released direction is measurably wrong:
1856/// on a two-variable QP the free direction after a release came back
1857/// [1.154, 0.194] against the analytic [1.227, 0.454].
1858/// A bound the classifier could not call active or inactive at the
1859/// base point: variable on the bound with a multiplier of the same
1860/// order as the slack, both order sqrt(mu). The solution map has a
1861/// kink there, and no single linear step is right for both sides.
1862#[derive(Clone, Copy, Debug)]
1863pub struct WeakBound {
1864    /// Bound-multiplier row in the compound KKT vector.
1865    pub row: usize,
1866    /// Var-x row of the variable the bound covers.
1867    pub var_row: usize,
1868    /// `true` when the bound is the variable's lower bound.
1869    pub lower: bool,
1870}
1871
1872/// Which operator a caller wants the walk's Schur pin applied to.
1873///
1874/// The pin is the same in all three cases: solve `K w - E du = r`
1875/// subject to `Eᵀ w = 0`. What varies is the `K` it rides on, and
1876/// [`Preferred`](PathOperator::Preferred) is the only one a solver
1877/// should use -- the other two exist so a test can name the operator
1878/// it is measuring instead of inferring which one ran.
1879#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1880pub enum PathOperator {
1881    /// The plain released system, falling back to the regularized one
1882    /// when it fails or does not hold the pins. What the walk uses.
1883    Preferred,
1884    /// The plain released system, and nothing else. Fails outright on
1885    /// a working set that leaves it singular.
1886    Plain,
1887    /// The released system with the pinned rows' diagonals raised
1888    /// until it is invertible. Always answers where the plain one
1889    /// does, with the same answer, at the cost of a refactorization
1890    /// per solve.
1891    Regularized,
1892}
1893
1894/// Pin residual (see [`pin_residual`]) below which the plain operator
1895/// is taken without trying the regularized one.
1896///
1897/// It is a **shortcut threshold, not a correctness one**: above it
1898/// both operators are run and the one that holds the pins better is
1899/// returned, so setting it too low costs a refactorization and never
1900/// costs an answer. Its job is to keep the common case -- a walk whose
1901/// Schur pin works -- on the cached factorization.
1902///
1903/// The two populations it sits between were measured on the gh#928
1904/// two-soft-bounds model, over the pinned solves its three curvature
1905/// arms take:
1906///
1907/// ```text
1908/// pin took     0, 1.4e-16, 1.5e-16, 1.8e-16, 1.9e-16   (n = 14)
1909/// pin missed   8.1e-9, 1.0e-1, 1.0e0   (n = 3, plus one outright refusal)
1910/// ```
1911///
1912/// `1e-11` is five orders above the worst residual a pin that took
1913/// leaves and three below the smallest one it misses by. An earlier
1914/// draft used `1e-8` and let the `8.1e-9` case through -- which is how
1915/// that row came to be measured rather than assumed.
1916const PIN_TAKE_RTOL: Number = 1e-11;
1917
1918/// How much of the pinned rows' motion the correction failed to
1919/// remove, as a fraction of the motion it was asked to remove.
1920///
1921/// `want` is what those rows read *before* the correction, which is
1922/// the pin's own right-hand side. Referencing the residual to it, and
1923/// not to the step as a whole, is the whole point: the compound
1924/// vector's multiplier rows run at `Sigma` scale -- `1e11` on the
1925/// gh#930 fixture -- so a residual divided by `max |d|` reads `3e-12`
1926/// on a pin that missed its target by 200%.
1927fn pin_residual(d: &[Number], pinned: &[usize], want: &[Number]) -> Number {
1928    let after = pinned
1929        .iter()
1930        .filter_map(|&i| d.get(i))
1931        .fold(0.0, |a: Number, v| a.max(v.abs()));
1932    if after == 0.0 {
1933        return 0.0;
1934    }
1935    let before = want.iter().fold(0.0, |a: Number, v| a.max(v.abs()));
1936    after / before.max(after)
1937}
1938
1939/// The step for the whole perturbation under the active set the path
1940/// has reached, taking whichever of the two operators actually holds
1941/// the pins.
1942///
1943/// The Schur complement is `-Eᵀ K⁻¹ E` on the *released* system, so
1944/// `K⁻¹` has to exist before a single hold is applied. Releasing a
1945/// bound takes that bound's `Sigma` off the diagonal, and on a model
1946/// with no curvature there two released variables sharing a constraint
1947/// are left with linearly dependent stationarity rows (gh#930).
1948/// Putting the diagonal back where the holds sit regularizes exactly
1949/// those rows and changes no answer, since the pin the Schur
1950/// complement then applies holds those coordinates at zero and
1951/// annihilates the term that was added.
1952///
1953/// **The plain operator does not always announce that it was
1954/// singular.** How loudly it fails depends on how rank-deficient it
1955/// is, which is a property of the model rather than of the defect: on
1956/// the gh#930 fixture two zero-curvature released rows coincide and
1957/// the augmented solve refuses outright, while adding curvature to a
1958/// *third* variable leaves the deficiency at one, the factorization
1959/// absorbs it, and the answer comes back `Ok` with the held variable
1960/// `1e-5` off its bound -- the silent half of the same defect. So the
1961/// choice between the operators is made on the pinned rows'
1962/// **residual**, not on whether the solve returned an error.
1963///
1964/// The regularized operator is second because it is not free: its
1965/// diagonal is rebuilt per solve, so the factorization cache misses
1966/// and every back-solve in the segment re-factors. A walk whose Schur
1967/// pin works must keep taking the plain one.
1968///
1969/// The last two arms never return less than the old contract did: a
1970/// direction the plain operator produced is still returned when
1971/// neither operator holds the pins, so a caller that used to get an
1972/// answer and let [`step_along_path`]'s box repair judge it still
1973/// does.
1974pub fn path_direction<B>(
1975    backsolver: &B,
1976    rhs_plain: &[Number],
1977    released: &[usize],
1978    pinned: &[usize],
1979) -> Result<(Vec<Number>, Vec<Number>), String>
1980where
1981    B: crate::backsolver::SensBacksolver + Clone,
1982{
1983    let plain = path_direction_on(backsolver, rhs_plain, released, pinned, false);
1984    if matches!(&plain, Ok((_, _, res)) if *res <= PIN_TAKE_RTOL) {
1985        return plain.map(|(d, du, _)| (d, du));
1986    }
1987    let reg = path_direction_on(backsolver, rhs_plain, released, pinned, true);
1988    match (plain, reg) {
1989        (Ok(p), Ok(r)) => Ok(if r.2 < p.2 { (r.0, r.1) } else { (p.0, p.1) }),
1990        (Ok(p), Err(_)) => Ok((p.0, p.1)),
1991        (Err(_), Ok(r)) => Ok((r.0, r.1)),
1992        (Err(e), Err(_)) => Err(e),
1993    }
1994}
1995
1996/// [`path_direction`] against one of the two operators the pin can be
1997/// applied to: the plain released system, or that system with the
1998/// pinned rows' diagonals raised enough to make it invertible.
1999///
2000/// Both take the *same* Schur pin afterwards, so both answer the same
2001/// question and report the hold forces in the same units -- which is
2002/// what lets a walk that switches between them mid-path keep
2003/// accumulating one multiplier per hold.
2004fn path_direction_on<B>(
2005    backsolver: &B,
2006    rhs_plain: &[Number],
2007    released: &[usize],
2008    pinned: &[usize],
2009    regularized: bool,
2010) -> Result<(Vec<Number>, Vec<Number>, Number), String>
2011where
2012    B: crate::backsolver::SensBacksolver + Clone,
2013{
2014    use crate::backsolver::SensBacksolver;
2015    use crate::sens_app::{SensApplication, SensOptions};
2016
2017    let n_full = backsolver.dim();
2018    let view = ReleasedView {
2019        base: backsolver.clone(),
2020        rows: released.to_vec(),
2021        pinned: if regularized {
2022            pinned.to_vec()
2023        } else {
2024            Vec::new()
2025        },
2026    };
2027    let mut d = vec![0.0; n_full];
2028    if !view.solve(rhs_plain, &mut d) {
2029        return Err("step_along_path: back-solve failed".into());
2030    }
2031    if pinned.is_empty() {
2032        return Ok((d, Vec::new(), 0.0));
2033    }
2034    // Hold each variable where the path left it, on its bound, by
2035    // asking the augmented system for the correction that takes its
2036    // further movement to zero.
2037    let rows: Vec<Index> = pinned.iter().map(|&r| r as Index).collect();
2038    let signs = vec![1; rows.len()];
2039    let mk =
2040        |r: Vec<Index>| IndexSchurData::from_parts(r, signs.clone()).map_err(|e| format!("{e:?}"));
2041    let opts = SensOptions {
2042        run_sens: true,
2043        ..SensOptions::default()
2044    };
2045    let mut app = SensApplication::new(mk(rows.clone())?, view, opts);
2046    let rhs: Vec<Number> = pinned.iter().map(|&i| d[i]).collect();
2047    let mut du = vec![0.0; rows.len()];
2048    let mut corr = vec![0.0; n_full];
2049    if !app.run_sens_step(&mk(rows)?, &rhs, &mut du, &mut corr) {
2050        return Err(format!(
2051            "step_along_path: augmented solve failed (holds {pinned:?}, released {released:?})"
2052        ));
2053    }
2054    for (k, v) in d.iter_mut().enumerate() {
2055        *v += corr[k];
2056    }
2057    let res = pin_residual(&d, pinned, &rhs);
2058    Ok((d, du, res))
2059}
2060
2061/// [`path_direction`] against an operator the caller names.
2062///
2063/// [`PathOperator::Preferred`] is exactly [`path_direction`]; the
2064/// other two skip the choice. The walk itself always takes
2065/// `Preferred`.
2066pub fn path_direction_with<B>(
2067    backsolver: &B,
2068    rhs_plain: &[Number],
2069    released: &[usize],
2070    pinned: &[usize],
2071    operator: PathOperator,
2072) -> Result<(Vec<Number>, Vec<Number>), String>
2073where
2074    B: crate::backsolver::SensBacksolver + Clone,
2075{
2076    match operator {
2077        PathOperator::Preferred => path_direction(backsolver, rhs_plain, released, pinned),
2078        PathOperator::Plain => path_direction_on(backsolver, rhs_plain, released, pinned, false)
2079            .map(|(d, du, _)| (d, du)),
2080        PathOperator::Regularized => {
2081            path_direction_on(backsolver, rhs_plain, released, pinned, true)
2082                .map(|(d, du, _)| (d, du))
2083        }
2084    }
2085}