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}