Skip to main content

pounce_sens_core/
rowlimit.rs

1//! Watching a limit that is written as a **constraint row**, on an
2//! engine whose KKT has no slack block (gh#929).
3//!
4//! # The gap
5//!
6//! [`crate::boundcheck`]'s walk and refinement decide everything in
7//! *primal KKT rows*: the box `lo`/`hi` and the base point `x_curr`
8//! index the primal prefix of the compound vector, and a
9//! [`BoundRow`](crate::backsolver::BoundRow) ties a multiplier row to
10//! the primal row it constrains. That is what lets the NLP arm watch a
11//! constraint's own limit — there, `dⱼ(x) = sⱼ` makes the limit a
12//! bound on a coordinate the KKT already carries, which is the gh#928
13//! fix.
14//!
15//! An active-set KKT has no such coordinate. `pounce-convex` assembles
16//!
17//! ```text
18//! [ H   Aᵀ  B_aᵀ ] [ dx ]
19//! [ A   0   0    ] [ dy ]
20//! [ B_a 0   0    ] [ dz ]
21//! ```
22//!
23//! where `B_a` holds the *active* rows only. A row `Gⱼ x ≤ hⱼ` that is
24//! **inactive** appears nowhere at all, so a step that drives `Gⱼ x`
25//! past `hⱼ` is not a breakpoint the walk can see — it is silence, and
26//! the answer comes back infeasible. An **active** row has a
27//! multiplier row but still no primal coordinate, so it cannot be
28//! reported as a `BoundRow` either, and a perturbation that drives its
29//! multiplier negative holds a row the solution has left.
30//!
31//! # The observer block
32//!
33//! This wrapper gives each watched row a coordinate of its own. For
34//! watched rows `G_w` it presents the augmented system
35//!
36//! ```text
37//! [ Kxx   0   Kxr  -G_wᵀ ] [ dx    ]   [ r_x   ]
38//! [ 0     0   0     I    ] [ dt    ] = [ r_t   ]
39//! [ Krx   0   Krr   0    ] [ drest ]   [ r_rest]
40//! [ -G_w  I   0     0    ] [ dmu   ]   [ r_mu  ]
41//! ```
42//!
43//! which is the base system with `t = G_w x` adjoined through a
44//! multiplier `mu` (Lagrangian term `muᵀ(t − G_w x)`). `t` sits
45//! immediately after the `x` block, so it lands inside the primal
46//! prefix the walk indexes, and carries the box `(−∞, h]`.
47//!
48//! **It costs no factorization.** The block is triangular in the
49//! adjoined variables:
50//!
51//! ```text
52//! dmu = r_t
53//! K [dx; drest] = [r_x + G_wᵀ r_t ; r_rest]      <- the base solve
54//! dt  = G_w dx + r_mu
55//! ```
56//!
57//! so one base back-solve and two sparse mat-vecs answer it, released
58//! or not. Nothing about the base factor changes, and an active row's
59//! release is still the base's own row-neutralization: the observer
60//! reads `dt = G_w dx + r_mu` whether or not the row it observes is
61//! being enforced.
62//!
63//! # Why the shift needs no special case
64//!
65//! [`SensBacksolver::solve_released_step`] moves a released
66//! multiplier onto the primal row it was acting on. Here that primal
67//! row is `tⱼ`, and `r_t` reaches the `x` rows as `G_wᵀ r_t` — which
68//! is exactly `zⱼ Gⱼᵀ`, the force the released row was applying. The
69//! observer's own algebra performs the conversion, so this file adds
70//! the shift in `t` and never writes a `G` row into an `x` right-hand
71//! side by hand.
72//!
73//! # Index spaces
74//!
75//! Two live here at once, which is the shape gh#450 and gh#764 say to
76//! be careful about. [`RowLimitView::to_base`] and
77//! [`RowLimitView::from_base`] are the only conversions, both total
78//! functions with an explicit `None` for the adjoined rows, and every
79//! read of a base-indexed vector goes through one of them.
80
81use pounce_common::types::Number;
82
83use crate::backsolver::{BoundRow, SensBacksolver};
84
85/// One watched limit: a sparse row `Σ coef·x[col] ≤ limit`, and the
86/// KKT row of its multiplier when the base system already enforces it.
87#[derive(Clone, Debug)]
88pub struct WatchedRow {
89    /// The row's nonzeros as `(column, coefficient)`, in the `x`
90    /// block's index space.
91    pub coefficients: Vec<(usize, Number)>,
92    /// The row's right-hand side, which becomes the observer's upper
93    /// bound. A row written `≥` must be negated by the caller before
94    /// it gets here; this type is one-sided on purpose, because a
95    /// two-sided row is two watched rows and pretending otherwise
96    /// hides which side a breakpoint belongs to.
97    pub limit: Number,
98    /// The value of `Σ coef·x[col]` at the base point.
99    pub base_value: Number,
100    /// `Some((multiplier_row, base_multiplier))` when the base system
101    /// carries this row in its active set, where `multiplier_row` is
102    /// in the **base** index space. `None` for an inactive row, which
103    /// the walk can then only reach and hold, never release.
104    pub active: Option<(usize, Number)>,
105}
106
107/// A [`SensBacksolver`] that adjoins an observer coordinate to each
108/// watched row of its base. See the module docs.
109#[derive(Clone)]
110pub struct RowLimitView<B> {
111    base: B,
112    n_x: usize,
113    rows: Vec<WatchedRow>,
114    /// The base's dimension, cached because it appears in every
115    /// conversion.
116    base_dim: usize,
117    /// Every releasable row this view offers the walk, in **this
118    /// view's** index space: the base's own bound rows shifted past the
119    /// observers, then one per watched row the base holds active.
120    ///
121    /// The concatenation is built here rather than left to the caller
122    /// because the walk reads a single slice and can only release what
123    /// it finds in it — a view that reported only its own rows would
124    /// silently take variable-bound releases away from the arm it
125    /// wraps.
126    bound_rows: Vec<BoundRow>,
127    /// Per watched row the base holds active: `(multiplier row in this
128    /// view's space, observer row, base multiplier)`, for the release
129    /// shift. Not parallel to [`Self::bound_rows`], which also carries
130    /// the base's.
131    shifts: Vec<(usize, usize, Number)>,
132}
133
134impl<B: SensBacksolver> RowLimitView<B> {
135    /// Wrap `base`, whose `x` block is `n_x` wide, with one observer
136    /// per entry of `rows`.
137    ///
138    /// `None` when the wrapper cannot be built honestly:
139    ///
140    /// * `n_x` past the base dimension, or a coefficient column past
141    ///   `n_x` — the caller's index space is not the one it thinks;
142    /// * an `active` multiplier row outside the base's non-`x` rows;
143    /// * **the base reports a
144    ///   [`natural_units_factor`](SensBacksolver::natural_units_factor)**.
145    ///   The observer rows have no entry in it, and inventing one
146    ///   would put a scaled right-hand side into an unscaled Schur
147    ///   complement. Refusing is the honest answer; the convex arm,
148    ///   the only caller, always answers `None` there because its KKT
149    ///   is assembled from raw problem data.
150    pub fn new(base: B, n_x: usize, rows: Vec<WatchedRow>) -> Option<Self> {
151        let base_dim = base.dim();
152        if n_x > base_dim || base.natural_units_factor().is_some() {
153            return None;
154        }
155        let n_t = rows.len();
156        let mut bound_rows: Vec<BoundRow> = Vec::new();
157        // The base's own rows first, shifted into this space. A base
158        // bound row whose `var_row` is not in the `x` block cannot be
159        // re-expressed here, because the observers were inserted
160        // immediately after `x`; refuse rather than mis-map it.
161        if let Some(base_rows) = base.bound_rows() {
162            for b in base_rows {
163                if b.var_row >= n_x || b.row < n_x || b.row >= base_dim {
164                    return None;
165                }
166                bound_rows.push(BoundRow {
167                    row: b.row + n_t,
168                    var_row: b.var_row,
169                    lower: b.lower,
170                });
171            }
172        }
173        let mut shifts = Vec::new();
174        for (j, r) in rows.iter().enumerate() {
175            if r.coefficients.iter().any(|&(c, _)| c >= n_x) {
176                return None;
177            }
178            if let Some((row, mult)) = r.active {
179                // An active row's multiplier lives in the base's
180                // non-primal rows; one inside the `x` block is a
181                // caller confusing the two spaces.
182                if row < n_x || row >= base_dim {
183                    return None;
184                }
185                let obs = n_x + j;
186                bound_rows.push(BoundRow {
187                    row: row + n_t,
188                    var_row: obs,
189                    // `G x ≤ h` is an upper limit on the observer.
190                    lower: false,
191                });
192                shifts.push((row + n_t, obs, mult));
193            }
194        }
195        Some(Self {
196            base,
197            n_x,
198            rows,
199            base_dim,
200            bound_rows,
201            shifts,
202        })
203    }
204
205    /// Number of observers, which is also the offset every base row at
206    /// or past the `x` block moves by.
207    pub fn n_observers(&self) -> usize {
208        self.rows.len()
209    }
210
211    /// This view's row for a base row. Total, and the only place the
212    /// `+ n_t` shift is written.
213    pub fn from_base(&self, base_row: usize) -> Option<usize> {
214        if base_row < self.n_x {
215            Some(base_row)
216        } else if base_row < self.base_dim {
217            Some(base_row + self.rows.len())
218        } else {
219            None
220        }
221    }
222
223    /// The base row behind a row of this view, or `None` for an
224    /// adjoined `t` or `mu` row — which is a real answer, not a
225    /// failure: those rows exist only here.
226    pub fn to_base(&self, row: usize) -> Option<usize> {
227        let n_t = self.rows.len();
228        if row < self.n_x {
229            Some(row)
230        } else if row < self.n_x + n_t {
231            None
232        } else if row < self.base_dim + n_t {
233            Some(row - n_t)
234        } else {
235            None
236        }
237    }
238
239    /// The walk's box and base point over the primal prefix `x` then
240    /// `t`, given the base's own `x`-block box and point.
241    ///
242    /// Returned rather than assembled by the caller so that the
243    /// observer's bounds and the observer's ordering cannot drift
244    /// apart from the ones [`Self::new`] built the `BoundRow`s
245    /// against.
246    pub fn primal_box(
247        &self,
248        x_curr: &[Number],
249        lo: &[Number],
250        hi: &[Number],
251    ) -> Option<(Vec<Number>, Vec<Number>, Vec<Number>)> {
252        if x_curr.len() != self.n_x || lo.len() != self.n_x || hi.len() != self.n_x {
253            return None;
254        }
255        let mut x = x_curr.to_vec();
256        let mut l = lo.to_vec();
257        let mut h = hi.to_vec();
258        for r in &self.rows {
259            x.push(r.base_value);
260            l.push(Number::NEG_INFINITY);
261            h.push(r.limit);
262        }
263        Some((x, l, h))
264    }
265
266    /// A base-space right-hand side lifted into this view's space.
267    pub fn lift_rhs(&self, base_rhs: &[Number]) -> Option<Vec<Number>> {
268        if base_rhs.len() != self.base_dim {
269            return None;
270        }
271        let mut out = vec![0.0; self.dim()];
272        for (i, &v) in base_rhs.iter().enumerate() {
273            let row = self.from_base(i)?;
274            out[row] = v;
275        }
276        Some(out)
277    }
278
279    /// A base-space **solution** lifted into this view's space, the
280    /// left-hand-side counterpart of [`Self::lift_rhs`].
281    ///
282    /// The observer coordinate of a step is `dt = G_w dx`, and the
283    /// adjoined multiplier of a step whose right-hand side came through
284    /// `lift_rhs` is zero — that right-hand side puts nothing in the
285    /// `t` or `mu` rows. So this is [`Self::unfold`] with both those
286    /// pieces zero, written that way rather than open-coded so a lifted
287    /// step cannot drift from what [`SensBacksolver::solve`] returns
288    /// for the same input. `lifting_a_step_agrees_with_solving_it`
289    /// is what holds the two together.
290    ///
291    /// A caller that already has the base step in hand — every one
292    /// does, since the plain step is what the refinement corrects —
293    /// pays two sparse mat-vecs here instead of a second back-solve.
294    pub fn lift_step(&self, base_lhs: &[Number]) -> Option<Vec<Number>> {
295        if base_lhs.len() != self.base_dim {
296            return None;
297        }
298        let zeros = vec![0.0; self.rows.len()];
299        let mut out = vec![0.0; self.dim()];
300        self.unfold(base_lhs, &zeros, &zeros, &mut out);
301        Some(out)
302    }
303
304    /// Every releasable row of this view, base and watched alike, in
305    /// this view's index space. Same slice
306    /// [`SensBacksolver::bound_rows`] returns.
307    pub fn all_bound_rows(&self) -> &[BoundRow] {
308        &self.bound_rows
309    }
310
311    /// The walk's multiplier list for this view: the base's own entries
312    /// lifted, then one per watched row the base holds active.
313    ///
314    /// Built here rather than by the caller so it cannot fall out of
315    /// step with [`Self::all_bound_rows`] — the walk releases a row
316    /// only when it finds it in *both* lists, so a row present in one
317    /// and missing from the other is a release that silently never
318    /// happens.
319    pub fn lift_multipliers(
320        &self,
321        base: &[crate::boundcheck::BoundMultiplier],
322    ) -> Option<Vec<crate::boundcheck::BoundMultiplier>> {
323        let mut out = Vec::with_capacity(base.len() + self.shifts.len());
324        for m in base {
325            out.push(crate::boundcheck::BoundMultiplier {
326                row: self.from_base(m.row)?,
327                base: m.base,
328            });
329        }
330        for &(row, _, mult) in &self.shifts {
331            out.push(crate::boundcheck::BoundMultiplier { row, base: mult });
332        }
333        Some(out)
334    }
335
336    /// Split a view-space right-hand side into the base solve's
337    /// right-hand side and the pieces the observer needs afterwards.
338    ///
339    /// `dmu = r_t`, and the base sees `r_x + G_wᵀ r_t`.
340    fn fold(&self, rhs: &[Number]) -> (Vec<Number>, Vec<Number>, Vec<Number>) {
341        let n_t = self.rows.len();
342        let r_t = rhs[self.n_x..self.n_x + n_t].to_vec();
343        let r_mu = rhs[self.base_dim + n_t..].to_vec();
344        let mut base_rhs = vec![0.0; self.base_dim];
345        base_rhs[..self.n_x].copy_from_slice(&rhs[..self.n_x]);
346        base_rhs[self.n_x..].copy_from_slice(&rhs[self.n_x + n_t..self.base_dim + n_t]);
347        for (j, r) in self.rows.iter().enumerate() {
348            let v = r_t[j];
349            if v != 0.0 {
350                for &(c, coef) in &r.coefficients {
351                    base_rhs[c] += coef * v;
352                }
353            }
354        }
355        (base_rhs, r_t, r_mu)
356    }
357
358    /// Scatter a base answer back into this view's space and fill the
359    /// observer rows: `dt = G_w dx + r_mu`, `dmu = r_t`.
360    fn unfold(&self, base_lhs: &[Number], r_t: &[Number], r_mu: &[Number], lhs: &mut [Number]) {
361        let n_t = self.rows.len();
362        lhs[..self.n_x].copy_from_slice(&base_lhs[..self.n_x]);
363        lhs[self.n_x + n_t..self.base_dim + n_t].copy_from_slice(&base_lhs[self.n_x..]);
364        for (j, r) in self.rows.iter().enumerate() {
365            let gx: Number = r
366                .coefficients
367                .iter()
368                .map(|&(c, coef)| coef * base_lhs[c])
369                .sum();
370            lhs[self.n_x + j] = gx + r_mu[j];
371            lhs[self.base_dim + n_t + j] = r_t[j];
372        }
373    }
374
375    /// Common body of the three solves: fold, run `f` on the base
376    /// system, unfold.
377    fn around<F>(&self, rhs: &[Number], lhs: &mut [Number], f: F) -> bool
378    where
379        F: FnOnce(&[Number], &mut [Number]) -> bool,
380    {
381        if rhs.len() != self.dim() || lhs.len() != self.dim() {
382            return false;
383        }
384        let (base_rhs, r_t, r_mu) = self.fold(rhs);
385        let mut base_lhs = vec![0.0; self.base_dim];
386        if !f(&base_rhs, &mut base_lhs) {
387            return false;
388        }
389        self.unfold(&base_lhs, &r_t, &r_mu, lhs);
390        true
391    }
392
393    /// `released`, in the base's index space, or `None` if any entry
394    /// is an adjoined row — which no caller should produce, since the
395    /// only releasable rows this view reports are base multiplier rows.
396    fn released_in_base(&self, released: &[usize]) -> Option<Vec<usize>> {
397        released.iter().map(|&r| self.to_base(r)).collect()
398    }
399}
400
401impl<B: SensBacksolver> SensBacksolver for RowLimitView<B> {
402    fn dim(&self) -> usize {
403        self.base_dim + 2 * self.rows.len()
404    }
405
406    fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
407        self.around(rhs, lhs, |b, l| self.base.solve(b, l))
408    }
409
410    /// `None`, and guaranteed rather than assumed:
411    /// [`RowLimitView::new`] refuses a base that reports one, because
412    /// the observer rows have no honest entry in it.
413    fn natural_units_factor(&self) -> Option<&[Number]> {
414        None
415    }
416
417    fn bound_rows(&self) -> Option<&[BoundRow]> {
418        Some(&self.bound_rows)
419    }
420
421    fn supports_release(&self) -> bool {
422        self.base.supports_release()
423    }
424
425    fn solve_released(&self, released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
426        let Some(key) = self.released_in_base(released) else {
427            return false;
428        };
429        self.around(rhs, lhs, |b, l| self.base.solve_released(&key, b, l))
430    }
431
432    /// The released step, with the observer carrying the shift.
433    ///
434    /// A released *row*'s multiplier is moved onto its observer row —
435    /// `r_t[j] += z` for the upper-limit orientation every watched row
436    /// has — and [`RowLimitView::fold`] then delivers it to the `x`
437    /// rows as `z Gⱼᵀ`. A released *variable bound* is not this view's
438    /// business and is left to the base's own shift.
439    fn solve_released_step(&self, released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
440        if rhs.len() != self.dim() {
441            return false;
442        }
443        let Some(key) = self.released_in_base(released) else {
444            return false;
445        };
446        let mut rhs = rhs.to_vec();
447        for &(row, obs, mult) in &self.shifts {
448            if !released.contains(&row) {
449                continue;
450            }
451            rhs[row] = 0.0;
452            rhs[obs] += mult;
453        }
454        self.around(&rhs, lhs, |b, l| self.base.solve_released_step(&key, b, l))
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::backsolver::DenseLuBacksolver;
462
463    /// A dense stand-in for a factored KKT that also answers the
464    /// release half of the trait, by refactoring with the released
465    /// rows neutralized the way a real backsolver does.
466    ///
467    /// It carries no bound rows of its own, so `solve_released_step`
468    /// has nothing to shift and coincides with `solve_released` — which
469    /// is the point: every shift these tests observe is one
470    /// [`RowLimitView`] put there.
471    struct Base {
472        n: usize,
473        k: Vec<Number>,
474        rows: Vec<BoundRow>,
475    }
476
477    impl Base {
478        fn factor(&self, released: &[usize]) -> Option<DenseLuBacksolver> {
479            let mut k = self.k.clone();
480            for &r in released {
481                for j in 0..self.n {
482                    k[r * self.n + j] = 0.0;
483                    k[j * self.n + r] = 0.0;
484                }
485                k[r * self.n + r] = -1.0;
486            }
487            DenseLuBacksolver::from_dense(self.n, &k).ok()
488        }
489    }
490
491    impl SensBacksolver for Base {
492        fn dim(&self) -> usize {
493            self.n
494        }
495        fn solve(&self, rhs: &[Number], lhs: &mut [Number]) -> bool {
496            self.factor(&[]).is_some_and(|f| f.solve(rhs, lhs))
497        }
498        fn bound_rows(&self) -> Option<&[BoundRow]> {
499            Some(&self.rows)
500        }
501        fn supports_release(&self) -> bool {
502            true
503        }
504        fn solve_released(&self, released: &[usize], rhs: &[Number], lhs: &mut [Number]) -> bool {
505            self.factor(released).is_some_and(|f| f.solve(rhs, lhs))
506        }
507        fn solve_released_step(
508            &self,
509            released: &[usize],
510            rhs: &[Number],
511            lhs: &mut [Number],
512        ) -> bool {
513            self.solve_released(released, rhs, lhs)
514        }
515    }
516
517    /// `n_x = 3`, two further rows, symmetric and nonsingular.
518    fn base(rows: Vec<BoundRow>) -> Base {
519        #[rustfmt::skip]
520        let k = vec![
521            2.0, 0.3, -0.4,  1.0, 0.0,
522            0.3, 1.7,  0.2,  0.0, 1.0,
523           -0.4, 0.2,  2.5,  1.0, 1.0,
524            1.0, 0.0,  1.0,  0.0, 0.0,
525            0.0, 1.0,  1.0,  0.0, 0.0,
526        ];
527        Base { n: 5, k, rows }
528    }
529
530    fn watched(active: Option<(usize, Number)>, coefficients: Vec<(usize, Number)>) -> WatchedRow {
531        WatchedRow {
532            coefficients,
533            limit: 1.0,
534            base_value: 0.25,
535            active,
536        }
537    }
538
539    /// The triangular solve is the augmented system, not merely
540    /// something that resembles it.
541    ///
542    /// The claim is checked against a dense factorization of the whole
543    /// `9 × 9` operator the module docs write out, on a right-hand side
544    /// with **all four blocks nonzero**. The `r_mu` block is the reason:
545    /// nothing on the convex arm ever produces one — `lift_rhs` zeroes
546    /// it — so `unfold` dropping `+ r_mu[j]` is invisible to every
547    /// integration test in the repo and visible here.
548    #[test]
549    fn the_triangular_solve_is_the_augmented_system() {
550        let n_x = 3;
551        let rows = vec![
552            watched(None, vec![(0, 1.0), (2, -0.5)]),
553            watched(None, vec![(1, 2.0)]),
554        ];
555        let b = base(Vec::new());
556        let base_k = b.k.clone();
557        let base_dim = b.n;
558        let n_t = rows.len();
559        let view = RowLimitView::new(b, n_x, rows.clone()).expect("the view must build");
560        let dim = view.dim();
561        assert_eq!(dim, base_dim + 2 * n_t);
562
563        // Assemble the augmented operator densely, in this view's row
564        // order: x, t, rest, mu.
565        let t0 = n_x;
566        let mu0 = base_dim + n_t;
567        let mut a = vec![0.0; dim * dim];
568        let view_of = |i: usize| if i < n_x { i } else { i + n_t };
569        for i in 0..base_dim {
570            for j in 0..base_dim {
571                a[view_of(i) * dim + view_of(j)] = base_k[i * base_dim + j];
572            }
573        }
574        for (j, r) in rows.iter().enumerate() {
575            // `I` in the t-row block against mu, and in the mu-row block
576            // against t.
577            a[(t0 + j) * dim + mu0 + j] = 1.0;
578            a[(mu0 + j) * dim + t0 + j] = 1.0;
579            for &(c, coef) in &r.coefficients {
580                a[c * dim + mu0 + j] = -coef;
581                a[(mu0 + j) * dim + c] = -coef;
582            }
583        }
584        let dense =
585            DenseLuBacksolver::from_dense(dim, &a).expect("the augmented system is regular");
586
587        let rhs: Vec<Number> = (0..dim).map(|i| 0.7 - 0.31 * (i as Number)).collect();
588        let mut want = vec![0.0; dim];
589        assert!(dense.solve(&rhs, &mut want));
590        let mut got = vec![0.0; dim];
591        assert!(view.solve(&rhs, &mut got));
592        for i in 0..dim {
593            assert!(
594                (got[i] - want[i]).abs() < 1e-10,
595                "row {i}: view {got:?} vs dense {want:?}",
596            );
597        }
598    }
599
600    /// Each active watched row gets **its own** observer, and the
601    /// release shift lands on that one.
602    ///
603    /// Both halves need two rows with different coefficients to say
604    /// anything: with a single watched row every ordering of the
605    /// observers is the same ordering, which is exactly the shape the
606    /// convex fixture has.
607    /// [`RowLimitView::lift_step`] is the cheap route to the augmented
608    /// plain step, and cheap is only worth having if it is the same
609    /// answer: a caller with the base step in hand skips a back-solve
610    /// by using it.
611    ///
612    /// The mutation this catches is the tempting one — lifting a step
613    /// by zero-filling the observers instead of evaluating
614    /// `dt = G_w dx`. That reads as a step whose watched rows do not
615    /// move at all, so nothing ever reaches a limit and the caller is
616    /// back to the defect it was fixing, with no error anywhere.
617    #[test]
618    fn lifting_a_step_agrees_with_solving_it() {
619        let n_x = 3;
620        let rows = vec![
621            watched(None, vec![(0, 1.0), (2, -0.5)]),
622            watched(Some((3, 0.7)), vec![(1, 2.0)]),
623        ];
624        let b = base(Vec::new());
625        let base_dim = b.n;
626        let view = RowLimitView::new(b, n_x, rows).expect("the view must build");
627
628        // A base-space right-hand side, lifted, solved in the view.
629        let base_rhs: Vec<Number> = vec![0.4, -1.1, 0.9, 0.3, -0.6];
630        let lifted_rhs = view.lift_rhs(&base_rhs).expect("the rhs lifts");
631        let mut want = vec![0.0; view.dim()];
632        assert!(view.solve(&lifted_rhs, &mut want), "the view must solve");
633
634        // The same thing from the base answer alone.
635        let mut base_lhs = vec![0.0; base_dim];
636        assert!(
637            view.base.solve(&base_rhs, &mut base_lhs),
638            "the base must solve",
639        );
640        let got = view.lift_step(&base_lhs).expect("the step lifts");
641
642        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
643            assert!(
644                (g - w).abs() < 1e-10,
645                "row {i}: lift_step {g} against solve {w}\n{got:?}\n{want:?}",
646            );
647        }
648        // Not vacuous: the observers actually moved.
649        assert!(
650            got[n_x..n_x + 2].iter().any(|v| v.abs() > 1e-6),
651            "the observers read {:?}, so this test would pass on a \
652             zero-filling lift",
653            &got[n_x..n_x + 2],
654        );
655    }
656
657    /// Length is the only thing `lift_step` can refuse, and refusing is
658    /// the point: a base answer of the wrong width is a caller holding
659    /// the view's own vector by mistake, which would otherwise scatter
660    /// into the observer rows.
661    #[test]
662    fn lift_step_refuses_a_vector_that_is_not_the_base() {
663        let rows = vec![watched(None, vec![(0, 1.0)])];
664        let view = RowLimitView::new(base(Vec::new()), 3, rows).expect("the view must build");
665        assert!(view.lift_step(&vec![0.0; view.dim()]).is_none());
666        assert!(view.lift_step(&[]).is_none());
667        assert!(view.lift_step(&vec![0.0; 5]).is_some());
668    }
669
670    #[test]
671    fn each_active_row_keeps_its_own_observer() {
672        let n_x = 3;
673        let (m0, m1) = (0.75, 0.2);
674        let rows = vec![
675            watched(Some((3, m0)), vec![(0, 1.0), (2, -0.5)]),
676            watched(Some((4, m1)), vec![(1, 2.0)]),
677        ];
678        let n_t = rows.len();
679        let b = base(Vec::new());
680        let reference = base(Vec::new());
681        let view = RowLimitView::new(b, n_x, rows.clone()).expect("the view must build");
682
683        // Multiplier row `3 + n_t` observes `n_x + 0`, `4 + n_t` observes
684        // `n_x + 1` — not the other way round.
685        assert_eq!(
686            view.all_bound_rows(),
687            &[
688                BoundRow {
689                    row: 3 + n_t,
690                    var_row: n_x,
691                    lower: false
692                },
693                BoundRow {
694                    row: 4 + n_t,
695                    var_row: n_x + 1,
696                    lower: false
697                },
698            ],
699        );
700        let lifted = view
701            .lift_multipliers(&[])
702            .expect("an empty base list still lifts");
703        assert_eq!(lifted.len(), 2);
704        for (got, want) in lifted.iter().zip([(3 + n_t, m0), (4 + n_t, m1)]) {
705            assert_eq!((got.row, got.base), want);
706        }
707
708        // Release the *first* row against a zero right-hand side. Its
709        // multiplier is the only force left, and `fold` delivers it as
710        // `m0 · G₀ᵀ` — so the answer is the released base solve on that
711        // vector, and would differ if the shift had landed on the other
712        // observer.
713        let released = vec![3 + n_t];
714        let mut got = vec![0.0; view.dim()];
715        assert!(view.solve_released_step(&released, &vec![0.0; view.dim()], &mut got));
716
717        let mut want_rhs = vec![0.0; 5];
718        for &(c, coef) in &rows[0].coefficients {
719            want_rhs[c] += coef * m0;
720        }
721        let mut want = vec![0.0; 5];
722        assert!(reference.solve_released(&[3], &want_rhs, &mut want));
723        for i in 0..n_x {
724            assert!(
725                (got[i] - want[i]).abs() < 1e-10,
726                "x row {i}: {got:?} vs {want:?}",
727            );
728        }
729        assert!(
730            got[..n_x].iter().any(|v| v.abs() > 1e-6),
731            "the shift must actually move something: {got:?}",
732        );
733    }
734}