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}