Skip to main content

pounce_common/
pd_perturbation.rs

1//! Primal-dual perturbation handler — port of
2//! `Algorithm/IpPDPerturbationHandler.{hpp,cpp}`.
3//!
4//! Lives in `pounce-common` so both KKT consumers can use it: the NLP
5//! filter-IPM (`pounce-algorithm`) and the active-set QP (`pounce-qp`). The
6//! dependency runs `pounce-algorithm -> pounce-qp`, so a shared home is the
7//! only way for the QP side to reach it without duplication.
8//!
9//! Owns the four perturbations `(δ_x, δ_s, δ_c, δ_d)` that the
10//! `PDFullSpaceSolver` adds to the augmented system to recover correct
11//! inertia / non-singularity. Implements upstream's full state
12//! machine:
13//!
14//! * [`Self::consider_new_system`] — first call per new aug-system.
15//!   Finalizes the previous trial's degeneracy probe, decides whether
16//!   to start a new degeneracy test, and seeds `δ_c` / `δ_d` if the
17//!   Jacobian is already known to be degenerate (or `perturb_always_cd`
18//!   is on).
19//! * [`Self::perturb_for_singular`] — escalation step taken when MA57
20//!   reports `Singular`.
21//! * [`Self::perturb_for_wrong_inertia`] — escalation step taken when
22//!   the factor's negative-eigenvalue count disagrees with what the
23//!   KKT structure requires.
24//! * [`Self::current_perturbation`] — read the most recently committed
25//!   `(δ_x, δ_s, δ_c, δ_d)`.
26//!
27//! Returns `false` when no further escalation is possible (caller must
28//! enter the restoration phase). The `info_string`-mutation calls in
29//! upstream are emitted via the `IpoptData` handle the caller passes
30//! in; if `None` is passed, the strings are simply dropped.
31
32use crate::types::{Index, Number};
33
34/// Sink for the two diagnostic writes the handler performs.
35///
36/// The handler is otherwise free-standing, so this is all that stood between
37/// it and being shared. `pounce-algorithm` implements it over its
38/// `IpoptDataHandle`; `pounce-qp` (which cannot depend on `pounce-algorithm` —
39/// the dependency runs the other way) passes `None` or its own sink.
40pub trait PerturbationSink {
41    /// Append to the iteration line's info string (upstream `info_string`).
42    fn append_info(&self, s: &str);
43    /// Record the primal regularization actually applied (upstream
44    /// `info_regu_x`), for the `lg(rg)` column.
45    fn set_regu_x(&self, v: Number);
46    /// Current iteration index, for the env-gated `POUNCE_DBG_PERT` trace.
47    fn iter_count(&self) -> Index {
48        -1
49    }
50    /// Emit one debug line. Defaulted to a no-op so this crate stays
51    /// dependency-light — `tracing` wiring belongs to the caller.
52    fn debug(&self, _msg: &str) {}
53}
54
55/// Trial state — port of upstream `TrialStatus` enum.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum TrialStatus {
58    NoTest,
59    DcEq0DxEq0,
60    DcGt0DxEq0,
61    DcEq0DxGt0,
62    DcGt0DxGt0,
63}
64
65/// Degeneracy state — port of `DegenType`.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum DegenType {
68    NotDegenerate,
69    Degenerate,
70    NotYetDetermined,
71}
72
73/// State + algorithmic parameters. Defaults mirror
74/// `IpPDPerturbationHandler.cpp::RegisterOptions`.
75#[derive(Debug, Clone)]
76pub struct PdPerturbationHandler {
77    // ---- algorithmic parameters (read from options) ----
78    pub delta_xs_max: Number,
79    pub delta_xs_min: Number,
80    pub delta_xs_first_inc_fact: Number,
81    pub delta_xs_inc_fact: Number,
82    pub delta_xs_dec_fact: Number,
83    pub delta_xs_init: Number,
84    pub delta_cd_val: Number,
85    pub delta_cd_exp: Number,
86    pub perturb_always_cd: bool,
87    pub reset_last: bool,
88    pub degen_iters_max: Index,
89
90    // ---- live state ----
91    pub delta_x_curr: Number,
92    pub delta_s_curr: Number,
93    pub delta_c_curr: Number,
94    pub delta_d_curr: Number,
95    pub delta_x_last: Number,
96    pub delta_s_last: Number,
97    pub delta_c_last: Number,
98    pub delta_d_last: Number,
99    pub get_deltas_for_wrong_inertia_called: bool,
100    pub hess_degenerate: DegenType,
101    pub jac_degenerate: DegenType,
102    pub degen_iters: Index,
103    pub test_status: TrialStatus,
104    /// gh#592: how many rungs of the `δ_x` ladder have been taken for
105    /// the current aug-system while `δ_c > 0`. Reset by
106    /// [`Self::consider_new_system`].
107    pub delta_c_rungs: Index,
108    /// gh#592: `δ_c` has already been withdrawn once for this
109    /// aug-system, so it must not be raised again before the next
110    /// [`Self::consider_new_system`]. Without the latch a linear solver
111    /// that keeps reporting `Singular` for the same reason would put it
112    /// straight back and the walk-back would cycle.
113    pub delta_c_abandoned: bool,
114    /// gh#592: the rung at which `δ_c` is withdrawn — see
115    /// [`Self::perturb_for_wrong_inertia`]. `0` disables the walk-back
116    /// and restores the pre-#592 escalation exactly.
117    pub delta_c_max_rungs: Index,
118    /// gh#981: the `δ_x` rung at which `δ_c` was withdrawn. The factor at
119    /// `(δ_x, δ_c)` reported `WrongInertia`, i.e. was nonsingular; a
120    /// certified `Singular` without `δ_c` refutes the withdrawal and the
121    /// ladder resumes from here.
122    pub delta_x_at_withdrawal: Number,
123    /// gh#981: `δ_c` was withdrawn and then reinstated for this
124    /// aug-system. Latched so that the walk-back does not fire a second
125    /// time; one withdrawal and one reinstatement per aug-system at most.
126    pub delta_c_reinstated: bool,
127    /// gh#981: `δ_c` has been withdrawn and no factorization without it
128    /// has reported back yet. Only the *first* `δ_c`-free factor can
129    /// refute the withdrawal: a dependent row shows up as a zero pivot
130    /// at the very first rung, every time, whereas a zero pivot that
131    /// appears at one rung of a long `δ_x` climb and not at its
132    /// neighbours is round-off (measured on `pooling_rt2stp`, where
133    /// honouring the later ones moved the exact leg to a different
134    /// local optimum).
135    pub withdrawal_untested: bool,
136}
137
138impl Default for PdPerturbationHandler {
139    fn default() -> Self {
140        Self {
141            delta_xs_max: 1e20,
142            delta_xs_min: 1e-20,
143            delta_xs_first_inc_fact: 100.0,
144            delta_xs_inc_fact: 8.0,
145            delta_xs_dec_fact: 1.0 / 3.0,
146            delta_xs_init: 1e-4,
147            delta_cd_val: 1e-8,
148            delta_cd_exp: 0.25,
149            perturb_always_cd: false,
150            reset_last: false,
151            degen_iters_max: 3,
152            delta_x_curr: 0.0,
153            delta_s_curr: 0.0,
154            delta_c_curr: 0.0,
155            delta_d_curr: 0.0,
156            delta_x_last: 0.0,
157            delta_s_last: 0.0,
158            delta_c_last: 0.0,
159            delta_d_last: 0.0,
160            get_deltas_for_wrong_inertia_called: false,
161            hess_degenerate: DegenType::NotYetDetermined,
162            jac_degenerate: DegenType::NotYetDetermined,
163            degen_iters: 0,
164            test_status: TrialStatus::NoTest,
165            // Three rungs is above anything the models δ_c exists for
166            // ever need: on eigena2 and eigenb2 (the gh#540 / gh#544
167            // motivating cases) δ_c is followed by at most one rung
168            // before the factor is accepted. See the doc comment on
169            // `perturb_for_wrong_inertia`.
170            delta_c_rungs: 0,
171            delta_c_abandoned: false,
172            delta_c_max_rungs: 3,
173            delta_x_at_withdrawal: 0.0,
174            delta_c_reinstated: false,
175            withdrawal_untested: false,
176        }
177    }
178}
179
180/// Snapshot of the four perturbations after a state-machine call.
181#[derive(Debug, Clone, Copy, PartialEq)]
182pub struct Deltas {
183    pub delta_x: Number,
184    pub delta_s: Number,
185    pub delta_c: Number,
186    pub delta_d: Number,
187}
188
189impl PdPerturbationHandler {
190    pub fn new() -> Self {
191        Self::default()
192    }
193
194    /// Configure `perturb_always_cd_` and rebuild the initial `jac`
195    /// state. Mirrors upstream's `InitializeImpl`.
196    pub fn set_perturb_always_cd(&mut self, on: bool) {
197        self.perturb_always_cd = on;
198        self.jac_degenerate = if on {
199            DegenType::NotDegenerate
200        } else {
201            DegenType::NotYetDetermined
202        };
203    }
204
205    /// First call when starting a new aug-system. `mu` is the current
206    /// barrier parameter (used by the `δ_cd` formula).
207    /// Returns `None` if no suitable starting perturbation could be
208    /// found (the caller bails).
209    pub fn consider_new_system(
210        &mut self,
211        mu: Number,
212        ip_data: Option<&dyn PerturbationSink>,
213    ) -> Option<Deltas> {
214        self.finalize_test(ip_data);
215
216        // Bookkeeping: roll the previous trial's `_curr` values into
217        // `_last` (matches upstream cpp:158-183).
218        if self.reset_last {
219            self.delta_x_last = self.delta_x_curr;
220            self.delta_s_last = self.delta_s_curr;
221            self.delta_c_last = self.delta_c_curr;
222            self.delta_d_last = self.delta_d_curr;
223        } else {
224            if self.delta_x_curr > 0.0 {
225                self.delta_x_last = self.delta_x_curr;
226            }
227            if self.delta_s_curr > 0.0 {
228                self.delta_s_last = self.delta_s_curr;
229            }
230            if self.delta_c_curr > 0.0 {
231                self.delta_c_last = self.delta_c_curr;
232            }
233            if self.delta_d_curr > 0.0 {
234                self.delta_d_last = self.delta_d_curr;
235            }
236        }
237
238        let undet = matches!(self.hess_degenerate, DegenType::NotYetDetermined)
239            || matches!(self.jac_degenerate, DegenType::NotYetDetermined);
240        self.test_status = if undet {
241            if self.perturb_always_cd {
242                TrialStatus::DcGt0DxEq0
243            } else {
244                TrialStatus::DcEq0DxEq0
245            }
246        } else {
247            TrialStatus::NoTest
248        };
249
250        let mut delta_c = if matches!(self.jac_degenerate, DegenType::Degenerate) {
251            let v = self.delta_cd(mu);
252            self.delta_c_curr = v;
253            append_info(ip_data, "l");
254            v
255        } else if self.perturb_always_cd {
256            let v = self.delta_cd(mu);
257            self.delta_c_curr = v;
258            v
259        } else {
260            self.delta_c_curr = 0.0;
261            0.0
262        };
263        let mut delta_d = delta_c;
264        self.delta_d_curr = delta_d;
265
266        let mut delta_x = 0.0;
267        let mut delta_s = 0.0;
268
269        if matches!(self.hess_degenerate, DegenType::Degenerate) {
270            self.delta_x_curr = 0.0;
271            self.delta_s_curr = 0.0;
272            if !self.get_deltas_for_wrong_inertia(
273                &mut delta_x,
274                &mut delta_s,
275                &mut delta_c,
276                &mut delta_d,
277                ip_data,
278            ) {
279                return None;
280            }
281        }
282
283        self.delta_x_curr = delta_x;
284        self.delta_s_curr = delta_s;
285        self.delta_c_curr = delta_c;
286        self.delta_d_curr = delta_d;
287        set_info_regu_x(ip_data, delta_x);
288        self.get_deltas_for_wrong_inertia_called = false;
289        // gh#592: the walk-back is scoped to a single aug-system.
290        self.delta_c_rungs = 0;
291        self.delta_c_abandoned = false;
292        self.delta_x_at_withdrawal = 0.0;
293        self.delta_c_reinstated = false;
294        self.withdrawal_untested = false;
295
296        Some(Deltas {
297            delta_x,
298            delta_s,
299            delta_c,
300            delta_d,
301        })
302    }
303
304    /// Escalation after `Singular` factorization status. Mirrors
305    /// `PerturbForSingularity` (cpp:245-364).
306    pub fn perturb_for_singular(
307        &mut self,
308        mu: Number,
309        ip_data: Option<&dyn PerturbationSink>,
310    ) -> Option<Deltas> {
311        self.perturb_for_singular_with(mu, false, ip_data)
312    }
313
314    /// [`Self::perturb_for_singular`] with the linear solver's verdict on
315    /// the report attached (gh#981). `certified` is true when the
316    /// factorization *found* a zero pivot — a rank deficiency it measured
317    /// — and false when `Singular` stands in for an inertia count the
318    /// solver declined to trust (gh#540). The two differ only after a
319    /// gh#592 walk-back: a certified `Singular` from the `δ_c`-free
320    /// factor refutes the withdrawal, because the factor at that `δ_x`
321    /// *with* `δ_c` was nonsingular and only `δ_c` changed, so the
322    /// singularity is in the constraint block and no `δ_x` can remove it.
323    /// `δ_c` is then put back and the ladder resumes from the withdrawal
324    /// rung. An uncertified `Singular` is answered on the `δ_x` ladder as
325    /// before — on gh#884's biactive MPCC every post-withdrawal `Singular`
326    /// is of that kind, and reinstating `δ_c` there costs the certificate.
327    pub fn perturb_for_singular_with(
328        &mut self,
329        mu: Number,
330        certified: bool,
331        ip_data: Option<&dyn PerturbationSink>,
332    ) -> Option<Deltas> {
333        let mut delta_x = 0.0;
334        let mut delta_s = 0.0;
335        let mut delta_c = 0.0;
336        let mut delta_d = 0.0;
337
338        // gh#592: `δ_c` has already been tried and withdrawn for this
339        // aug-system (see `maybe_withdraw_delta_c`). A further
340        // `Singular` is the same evidence that did not respond to it,
341        // so answer it on the `δ_x` ladder rather than putting `δ_c`
342        // straight back — which is what the arms below would do, and
343        // would cycle.
344        let first_without_delta_c = self.withdrawal_untested;
345        self.withdrawal_untested = false;
346        if self.delta_c_abandoned && certified && first_without_delta_c && !self.delta_c_reinstated
347        {
348            // gh#981: the withdrawal is refuted — see the doc comment.
349            self.delta_c_abandoned = false;
350            self.delta_c_reinstated = true;
351            let v = self.delta_cd(mu);
352            self.delta_c_curr = v;
353            self.delta_d_curr = v;
354            // The factor at the withdrawal rung with δ_c on is already
355            // known to be `WrongInertia`; resume the ladder from there so
356            // the rungs below it are not climbed a second time.
357            if self.delta_x_curr < self.delta_x_at_withdrawal {
358                self.delta_x_curr = self.delta_x_at_withdrawal;
359                self.delta_s_curr = self.delta_x_at_withdrawal;
360            }
361            self.test_status = TrialStatus::NoTest;
362            append_info(ip_data, "W");
363            if !self.get_deltas_for_wrong_inertia(
364                &mut delta_x,
365                &mut delta_s,
366                &mut delta_c,
367                &mut delta_d,
368                ip_data,
369            ) {
370                return None;
371            }
372            set_info_regu_x(ip_data, self.delta_x_curr);
373            return Some(Deltas {
374                delta_x: self.delta_x_curr,
375                delta_s: self.delta_s_curr,
376                delta_c: self.delta_c_curr,
377                delta_d: self.delta_d_curr,
378            });
379        }
380        if self.delta_c_abandoned {
381            self.test_status = TrialStatus::NoTest;
382            if !self.get_deltas_for_wrong_inertia(
383                &mut delta_x,
384                &mut delta_s,
385                &mut delta_c,
386                &mut delta_d,
387                ip_data,
388            ) {
389                return None;
390            }
391            set_info_regu_x(ip_data, self.delta_x_curr);
392            return Some(Deltas {
393                delta_x: self.delta_x_curr,
394                delta_s: self.delta_s_curr,
395                delta_c: self.delta_c_curr,
396                delta_d: self.delta_d_curr,
397            });
398        }
399
400        // Upstream's `TrialStatus` arms below assert that the degeneracy
401        // probe is still in the state that named it — `δ_x == 0` for the
402        // `DxEq0` statuses, and so on. Those are `DBG_ASSERT`s upstream,
403        // i.e. assumptions rather than invariants: every real linear
404        // solver can report `Singular` from *any* rung of the δ_x ladder
405        // (MUMPS `INFO(1) = -10`, MA27 `IFLAG = 3`, and — since pounce
406        // gh#540 — feral's inertia-trust floor), at which point the probe
407        // has already been disturbed and its arm no longer applies.
408        // Abandon the probe and fall through to the determined-state path,
409        // which asserts nothing and does the right thing from wherever the
410        // perturbations happen to be: raise δ_c if it is still zero,
411        // otherwise take a δ_x step.
412        let probe_intact = match self.test_status {
413            TrialStatus::DcEq0DxEq0 => self.delta_x_curr == 0.0 && self.delta_c_curr == 0.0,
414            TrialStatus::DcGt0DxEq0 => self.delta_x_curr == 0.0 && self.delta_c_curr > 0.0,
415            TrialStatus::DcEq0DxGt0 => self.delta_x_curr > 0.0 && self.delta_c_curr == 0.0,
416            TrialStatus::DcGt0DxGt0 | TrialStatus::NoTest => true,
417        };
418        if !probe_intact {
419            self.test_status = TrialStatus::NoTest;
420        }
421
422        let undet = probe_intact
423            && (matches!(self.hess_degenerate, DegenType::NotYetDetermined)
424                || matches!(self.jac_degenerate, DegenType::NotYetDetermined));
425        if undet {
426            match self.test_status {
427                TrialStatus::DcEq0DxEq0 => {
428                    debug_assert!(self.delta_x_curr == 0.0 && self.delta_c_curr == 0.0);
429                    if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
430                        let v = self.delta_cd(mu);
431                        self.delta_c_curr = v;
432                        self.delta_d_curr = v;
433                        self.test_status = TrialStatus::DcGt0DxEq0;
434                    } else {
435                        debug_assert!(matches!(self.hess_degenerate, DegenType::NotYetDetermined));
436                        if !self.get_deltas_for_wrong_inertia(
437                            &mut delta_x,
438                            &mut delta_s,
439                            &mut delta_c,
440                            &mut delta_d,
441                            ip_data,
442                        ) {
443                            return None;
444                        }
445                        self.test_status = TrialStatus::DcEq0DxGt0;
446                    }
447                }
448                TrialStatus::DcGt0DxEq0 => {
449                    debug_assert!(self.delta_x_curr == 0.0 && self.delta_c_curr > 0.0);
450                    debug_assert!(matches!(self.jac_degenerate, DegenType::NotYetDetermined));
451                    if !self.perturb_always_cd {
452                        self.delta_c_curr = 0.0;
453                        self.delta_d_curr = 0.0;
454                        if !self.get_deltas_for_wrong_inertia(
455                            &mut delta_x,
456                            &mut delta_s,
457                            &mut delta_c,
458                            &mut delta_d,
459                            ip_data,
460                        ) {
461                            return None;
462                        }
463                        self.test_status = TrialStatus::DcEq0DxGt0;
464                    } else if !self.get_deltas_for_wrong_inertia(
465                        &mut delta_x,
466                        &mut delta_s,
467                        &mut delta_c,
468                        &mut delta_d,
469                        ip_data,
470                    ) {
471                        return None;
472                    } else {
473                        self.test_status = TrialStatus::DcGt0DxGt0;
474                    }
475                }
476                TrialStatus::DcEq0DxGt0 => {
477                    debug_assert!(self.delta_x_curr > 0.0 && self.delta_c_curr == 0.0);
478                    let v = self.delta_cd(mu);
479                    self.delta_c_curr = v;
480                    self.delta_d_curr = v;
481                    if !self.get_deltas_for_wrong_inertia(
482                        &mut delta_x,
483                        &mut delta_s,
484                        &mut delta_c,
485                        &mut delta_d,
486                        ip_data,
487                    ) {
488                        return None;
489                    }
490                    self.test_status = TrialStatus::DcGt0DxGt0;
491                }
492                TrialStatus::DcGt0DxGt0 => {
493                    if !self.get_deltas_for_wrong_inertia(
494                        &mut delta_x,
495                        &mut delta_s,
496                        &mut delta_c,
497                        &mut delta_d,
498                        ip_data,
499                    ) {
500                        return None;
501                    }
502                }
503                TrialStatus::NoTest => {
504                    debug_assert!(false, "perturb_for_singular: NoTest in undetermined branch");
505                }
506            }
507        } else if self.delta_c_curr > 0.0 {
508            // Already perturbed C; treat as wrong-inertia.
509            if !self.get_deltas_for_wrong_inertia(
510                &mut delta_x,
511                &mut delta_s,
512                &mut delta_c,
513                &mut delta_d,
514                ip_data,
515            ) {
516                return None;
517            }
518        } else {
519            let v = self.delta_cd(mu);
520            self.delta_c_curr = v;
521            self.delta_d_curr = v;
522            append_info(ip_data, "L");
523        }
524
525        let out = Deltas {
526            delta_x: self.delta_x_curr,
527            delta_s: self.delta_s_curr,
528            delta_c: self.delta_c_curr,
529            delta_d: self.delta_d_curr,
530        };
531        set_info_regu_x(ip_data, out.delta_x);
532        Some(out)
533    }
534
535    /// Escalation after `WrongInertia` factorization status. Mirrors
536    /// `PerturbForWrongInertia` (cpp:419-450).
537    pub fn perturb_for_wrong_inertia(
538        &mut self,
539        mu: Number,
540        ip_data: Option<&dyn PerturbationSink>,
541    ) -> Option<Deltas> {
542        if std::env::var_os("POUNCE_DBG_PERT").is_some() {
543            if let Some(d) = ip_data {
544                d.debug(&format!(
545                    "[PERT] iter={} WRONG_INERTIA mu={:.2e} dx_last={:.2e} dx_curr={:.2e}",
546                    d.iter_count(),
547                    mu,
548                    self.delta_x_last,
549                    self.delta_x_curr
550                ));
551            }
552        }
553        self.finalize_test(ip_data);
554        // gh#981: a `WrongInertia` from the first `δ_c`-free factor is
555        // gh#592's full-rank case; the withdrawal stands.
556        self.withdrawal_untested = false;
557        self.maybe_withdraw_delta_c(ip_data);
558
559        let mut delta_x = 0.0;
560        let mut delta_s = 0.0;
561        let mut delta_c = 0.0;
562        let mut delta_d = 0.0;
563        let mut ok = self.get_deltas_for_wrong_inertia(
564            &mut delta_x,
565            &mut delta_s,
566            &mut delta_c,
567            &mut delta_d,
568            ip_data,
569        );
570        // Upstream "no progress on δ_x but δ_c == 0" recovery: bring
571        // up the C/D perturbation, reset Hessian degeneracy, retry.
572        // Upstream peeks at the OUT-parameter `delta_c`, but
573        // `get_deltas_for_wrong_inertia` only writes that on success;
574        // we look at the handler's own δ_c_curr instead, which
575        // matches the algorithmic intent unambiguously.
576        if !ok && self.delta_c_curr == 0.0 {
577            debug_assert_eq!(self.delta_d_curr, 0.0);
578            let v = self.delta_cd(mu);
579            self.delta_c_curr = v;
580            self.delta_d_curr = v;
581            self.delta_x_curr = 0.0;
582            self.delta_s_curr = 0.0;
583            self.test_status = TrialStatus::NoTest;
584            if matches!(self.hess_degenerate, DegenType::Degenerate) {
585                self.hess_degenerate = DegenType::NotYetDetermined;
586            }
587            ok = self.get_deltas_for_wrong_inertia(
588                &mut delta_x,
589                &mut delta_s,
590                &mut delta_c,
591                &mut delta_d,
592                ip_data,
593            );
594        }
595        if !ok {
596            return None;
597        }
598        Some(Deltas {
599            delta_x,
600            delta_s,
601            delta_c,
602            delta_d,
603        })
604    }
605
606    /// gh#592: withdraw `δ_c` once it has demonstrably failed to buy a
607    /// usable inertia.
608    ///
609    /// `δ_c` is the perturbation for a rank-deficient constraint
610    /// Jacobian, and it is reached for when the factorization reports
611    /// `Singular`. Since gh#540 a factorization also reports `Singular`
612    /// when its inertia is *unmeasurable* — the count disagrees and the
613    /// smallest pivot is at the noise floor — which is evidence about
614    /// the measurement, not about the Jacobian's rank. When the
615    /// Jacobian in fact has full rank, `δ_c` cannot help, and because it
616    /// stays switched on for the rest of the aug-system the `δ_x` ladder
617    /// then has to climb against a matrix `δ_c` has made *harder* to hit
618    /// the requested inertia on: on the gh#592 model this cost five
619    /// rungs, ending at `δ_w = 1e2` where Ipopt accepted the step at
620    /// `1e-4`, and the over-damped step froze the objective for the next
621    /// eight iterations before the loose-tolerance exit test fired.
622    ///
623    /// So rather than predict which kind of `Singular` this was — the
624    /// counts are the very thing gh#540 established are noise — let the
625    /// ladder answer it. After `delta_c_max_rungs` rungs with `δ_c` on
626    /// and still no acceptable inertia, `δ_c` has had its chance:
627    /// withdraw it, restart the `δ_x` ladder, and latch it off for the
628    /// remainder of this aug-system. Where `δ_c` is the right remedy
629    /// this never fires — on eigena2 and eigenb2 it is followed by at
630    /// most one rung.
631    fn maybe_withdraw_delta_c(&mut self, ip_data: Option<&dyn PerturbationSink>) {
632        if self.delta_c_max_rungs <= 0
633            || self.delta_c_abandoned
634            || self.delta_c_reinstated
635            || self.delta_c_curr <= 0.0
636        {
637            return;
638        }
639        self.delta_c_rungs += 1;
640        if self.delta_c_rungs < self.delta_c_max_rungs {
641            return;
642        }
643        self.delta_x_at_withdrawal = self.delta_x_curr;
644        self.withdrawal_untested = true;
645        self.delta_c_curr = 0.0;
646        self.delta_d_curr = 0.0;
647        self.delta_x_curr = 0.0;
648        self.delta_s_curr = 0.0;
649        self.delta_c_abandoned = true;
650        self.test_status = TrialStatus::NoTest;
651        append_info(ip_data, "w");
652    }
653
654    /// Read the most recently committed perturbations.
655    pub fn current_perturbation(&self) -> Deltas {
656        Deltas {
657            delta_x: self.delta_x_curr,
658            delta_s: self.delta_s_curr,
659            delta_c: self.delta_c_curr,
660            delta_d: self.delta_d_curr,
661        }
662    }
663
664    /// Internal — pure escalation of `δ_x` / `δ_s`. Returns `false` if
665    /// `δ_x` would exceed `delta_xs_max`. Mirrors
666    /// `get_deltas_for_wrong_inertia`.
667    fn get_deltas_for_wrong_inertia(
668        &mut self,
669        delta_x: &mut Number,
670        delta_s: &mut Number,
671        delta_c: &mut Number,
672        delta_d: &mut Number,
673        ip_data: Option<&dyn PerturbationSink>,
674    ) -> bool {
675        if self.delta_x_curr == 0.0 {
676            self.delta_x_curr = if self.delta_x_last == 0.0 {
677                self.delta_xs_init
678            } else {
679                self.delta_xs_min
680                    .max(self.delta_x_last * self.delta_xs_dec_fact)
681            };
682        } else if self.delta_x_last == 0.0 || 1e5 * self.delta_x_last < self.delta_x_curr {
683            self.delta_x_curr *= self.delta_xs_first_inc_fact;
684        } else {
685            self.delta_x_curr *= self.delta_xs_inc_fact;
686        }
687        if self.delta_x_curr > self.delta_xs_max {
688            self.delta_x_last = 0.0;
689            self.delta_s_last = 0.0;
690            // The ladder has given up on this system; forget the rung that
691            // overflowed as well. Otherwise the next `consider_new_system`
692            // (reset_last == false) promotes this over-max `delta_x_curr`
693            // into `delta_x_last`, undoing the reset above, and every later
694            // ladder starts at max(min, last * dec_fact) >= delta_xs_max/3 and
695            // overflows again within one or two rungs -- a self-sustaining
696            // latch. Upstream IpPDPerturbationHandler.cpp:395-402 / :166-169
697            // carries the same latent defect. Measured on pooling_rt2stp
698            // (x86_64, feral 0.21): 161 overflows, 167 restoration entries and
699            // a gh#274 exit at 848 iterations without this; 492 and
700            // `Solve_Succeeded` with it.
701            self.delta_x_curr = 0.0;
702            self.delta_s_curr = 0.0;
703            append_info(ip_data, "dx");
704            return false;
705        }
706        self.delta_s_curr = self.delta_x_curr;
707
708        *delta_x = self.delta_x_curr;
709        *delta_s = self.delta_s_curr;
710        *delta_c = self.delta_c_curr;
711        *delta_d = self.delta_d_curr;
712        set_info_regu_x(ip_data, *delta_x);
713        self.get_deltas_for_wrong_inertia_called = true;
714        true
715    }
716
717    fn delta_cd(&self, mu: Number) -> Number {
718        self.delta_cd_val * mu.powf(self.delta_cd_exp)
719    }
720
721    /// Read the test outcome from the just-completed (non-singular)
722    /// factor and update degeneracy flags. Mirrors `finalize_test`
723    /// (cpp:470-538).
724    fn finalize_test(&mut self, ip_data: Option<&dyn PerturbationSink>) {
725        match self.test_status {
726            TrialStatus::NoTest => (),
727            TrialStatus::DcEq0DxEq0 => {
728                if matches!(self.hess_degenerate, DegenType::NotYetDetermined)
729                    && matches!(self.jac_degenerate, DegenType::NotYetDetermined)
730                {
731                    self.hess_degenerate = DegenType::NotDegenerate;
732                    self.jac_degenerate = DegenType::NotDegenerate;
733                    append_info(ip_data, "Nhj ");
734                } else if matches!(self.hess_degenerate, DegenType::NotYetDetermined) {
735                    self.hess_degenerate = DegenType::NotDegenerate;
736                    append_info(ip_data, "Nh ");
737                } else if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
738                    self.jac_degenerate = DegenType::NotDegenerate;
739                    append_info(ip_data, "Nj ");
740                }
741            }
742            TrialStatus::DcGt0DxEq0 => {
743                if matches!(self.hess_degenerate, DegenType::NotYetDetermined) {
744                    self.hess_degenerate = DegenType::NotDegenerate;
745                    append_info(ip_data, "Nh ");
746                }
747                if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
748                    self.degen_iters += 1;
749                    if self.degen_iters >= self.degen_iters_max {
750                        self.jac_degenerate = DegenType::Degenerate;
751                        append_info(ip_data, "Dj ");
752                    }
753                    append_info(ip_data, "L");
754                }
755            }
756            TrialStatus::DcEq0DxGt0 => {
757                if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
758                    self.jac_degenerate = DegenType::NotDegenerate;
759                    append_info(ip_data, "Nj ");
760                }
761                if matches!(self.hess_degenerate, DegenType::NotYetDetermined) {
762                    self.degen_iters += 1;
763                    if self.degen_iters >= self.degen_iters_max {
764                        self.hess_degenerate = DegenType::Degenerate;
765                        append_info(ip_data, "Dh ");
766                    }
767                }
768            }
769            TrialStatus::DcGt0DxGt0 => {
770                self.degen_iters += 1;
771                if self.degen_iters >= self.degen_iters_max {
772                    self.hess_degenerate = DegenType::Degenerate;
773                    self.jac_degenerate = DegenType::Degenerate;
774                    append_info(ip_data, "Dhj ");
775                }
776                append_info(ip_data, "L");
777            }
778        }
779    }
780}
781
782fn append_info(sink: Option<&dyn PerturbationSink>, s: &str) {
783    if let Some(h) = sink {
784        h.append_info(s);
785    }
786}
787
788fn set_info_regu_x(sink: Option<&dyn PerturbationSink>, v: Number) {
789    if let Some(h) = sink {
790        h.set_regu_x(v);
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    /// A ladder that overflows `δ_xs_max` gives up on the system *and*
799    /// forgets it: the next system's ladder starts from the first rung, not
800    /// from one third of the cap. Without clearing `δ_x_curr` on overflow,
801    /// `consider_new_system` promoted the over-max value into `δ_x_last`
802    /// and every later ladder overflowed within one or two rungs
803    /// (pooling_rt2stp on x86_64 / feral 0.21: 161 overflows, gh#274 exit at
804    /// 848 iterations). Revert the two `*_curr = 0.0` lines and this fails.
805    #[test]
806    fn an_overflowed_ladder_does_not_poison_the_next_system() {
807        let mut h = PdPerturbationHandler::new();
808        h.consider_new_system(0.1, None).unwrap();
809        let mut rungs = 0;
810        while h.perturb_for_wrong_inertia(0.1, None).is_some() {
811            rungs += 1;
812            assert!(rungs < 1000, "the ladder never reached delta_xs_max");
813        }
814
815        h.consider_new_system(0.1, None).unwrap();
816        assert_eq!(h.delta_x_last, 0.0, "the overflowed rung was remembered");
817        let d = h
818            .perturb_for_wrong_inertia(0.1, None)
819            .expect("a fresh system's first rung must not fail");
820        assert!(
821            d.delta_x < h.delta_xs_max / 1e6,
822            "the next ladder started at {:e}, near the cap {:e}",
823            d.delta_x,
824            h.delta_xs_max,
825        );
826    }
827
828    /// gh#592: three rungs of the `δ_x` ladder with `δ_c` on and still
829    /// no acceptable inertia is `δ_c` failing at the job it was raised
830    /// for, so it is withdrawn and the ladder restarts without it.
831    #[test]
832    fn delta_c_is_withdrawn_after_the_ladder_has_climbed_without_it() {
833        let mut h = PdPerturbationHandler::new();
834        h.consider_new_system(0.1, None).unwrap();
835
836        // A `Singular` factor raises δ_c on its own — no δ_x yet.
837        let d = h.perturb_for_singular(0.1, None).unwrap();
838        assert!(d.delta_c > 0.0, "δ_c should be the first response");
839        assert_eq!(d.delta_x, 0.0);
840
841        // Rungs one and two: δ_c stays, because it may yet pay off.
842        for rung in 1..=2 {
843            let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
844            assert!(d.delta_c > 0.0, "δ_c withdrawn early, at rung {rung}");
845            assert!(d.delta_x > 0.0);
846        }
847
848        // Rung three: withdrawn, and the ladder restarts from the low
849        // rung rather than carrying the height it reached under δ_c.
850        let before = h.delta_x_curr;
851        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
852        assert_eq!(d.delta_c, 0.0, "δ_c was not withdrawn at the third rung");
853        assert_eq!(d.delta_d, 0.0);
854        assert!(
855            d.delta_x > 0.0 && d.delta_x < before,
856            "the ladder did not restart"
857        );
858    }
859
860    /// ...and it stays withdrawn for the rest of this aug-system. The
861    /// factorization that reported `Singular` will keep reporting it for
862    /// the same reason, so without the latch δ_c would go straight back
863    /// on and the walk-back would cycle.
864    #[test]
865    fn a_withdrawn_delta_c_is_not_raised_again_until_the_next_iterate() {
866        let mut h = PdPerturbationHandler::new();
867        h.consider_new_system(0.1, None).unwrap();
868        h.perturb_for_singular(0.1, None).unwrap();
869        for _ in 0..3 {
870            h.perturb_for_wrong_inertia(0.1, None).unwrap();
871        }
872        assert!(h.delta_c_abandoned);
873
874        let d = h.perturb_for_singular(0.1, None).unwrap();
875        assert_eq!(d.delta_c, 0.0, "δ_c came back inside the same aug-system");
876        assert!(d.delta_x > 0.0, "the `Singular` was not answered at all");
877
878        // The next iterate starts clean: the withdrawal is a statement
879        // about one aug-system, not about the problem.
880        h.consider_new_system(0.1, None).unwrap();
881        assert!(!h.delta_c_abandoned);
882        assert_eq!(h.delta_c_rungs, 0);
883        let d = h.perturb_for_singular(0.1, None).unwrap();
884        assert!(d.delta_c > 0.0, "δ_c is still latched off a new aug-system");
885    }
886
887    /// Where `δ_c` is the right remedy the walk-back must be invisible.
888    /// One rung is the whole pattern on eigena2 and eigenb2, the models
889    /// gh#540 / gh#544 raised δ_c for.
890    #[test]
891    fn one_rung_under_delta_c_leaves_it_alone() {
892        let mut h = PdPerturbationHandler::new();
893        h.consider_new_system(0.1, None).unwrap();
894        h.perturb_for_singular(0.1, None).unwrap();
895        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
896        assert!(d.delta_c > 0.0);
897        // A good factor ends the aug-system; the counter resets with it.
898        h.consider_new_system(0.1, None).unwrap();
899        assert_eq!(h.delta_c_rungs, 0);
900    }
901
902    /// The opt-out restores the pre-#592 escalation exactly: δ_c on,
903    /// and the δ_x ladder climbing against it without bound.
904    #[test]
905    fn zero_max_rungs_disables_the_walkback() {
906        let mut h = PdPerturbationHandler::new();
907        h.delta_c_max_rungs = 0;
908        h.consider_new_system(0.1, None).unwrap();
909        h.perturb_for_singular(0.1, None).unwrap();
910        let mut last = 0.0;
911        for _ in 0..6 {
912            let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
913            assert!(d.delta_c > 0.0, "δ_c was withdrawn with the walk-back off");
914            assert!(d.delta_x > last, "the ladder stopped climbing");
915            last = d.delta_x;
916        }
917        assert!(!h.delta_c_abandoned);
918    }
919
920    #[test]
921    fn first_wrong_inertia_perturbation_is_delta_xs_init() {
922        let mut h = PdPerturbationHandler::new();
923        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
924        // delta_xs_init = first_hessian_perturbation = 1e-4
925        assert!((d.delta_x - 1e-4).abs() < 1e-20);
926        assert_eq!(d.delta_x, d.delta_s);
927        assert_eq!(d.delta_c, 0.0);
928        assert_eq!(d.delta_d, 0.0);
929    }
930
931    #[test]
932    fn second_perturbation_uses_first_inc_fact() {
933        // After the *first* nonzero δ_x, with δ_x_last == 0, the
934        // doubling uses `delta_xs_first_inc_fact = 100` per upstream
935        // (cpp:386-389: "if delta_x_last_ == 0 ...").
936        let mut h = PdPerturbationHandler::new();
937        let d1 = h.perturb_for_wrong_inertia(0.1, None).unwrap();
938        let d2 = h.perturb_for_wrong_inertia(0.1, None).unwrap();
939        assert!((d2.delta_x - d1.delta_x * 100.0).abs() < 1e-15);
940    }
941
942    #[test]
943    fn third_perturbation_uses_inc_fact() {
944        // After delta_x_last has been set (via consider_new_system or
945        // first inc), continued growth uses `delta_xs_inc_fact = 8`.
946        let mut h = PdPerturbationHandler::new();
947        h.delta_x_curr = 1e-2;
948        h.delta_x_last = 1e-2;
949        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
950        assert!((d.delta_x - 1e-2 * 8.0).abs() < 1e-15);
951    }
952
953    #[test]
954    fn perturbation_caps_at_max_when_dcd_already_active() {
955        // When δ_c is already > 0 (e.g., perturb_always_cd, or after a
956        // singular-recovery), the fallback path inside
957        // `perturb_for_wrong_inertia` is skipped and the
958        // δ_x-overflow surfaces as `None`.
959        let mut h = PdPerturbationHandler::new();
960        h.delta_x_curr = h.delta_xs_max;
961        h.delta_c_curr = 1e-4;
962        h.delta_d_curr = 1e-4;
963        assert!(h.perturb_for_wrong_inertia(0.1, None).is_none());
964    }
965
966    #[test]
967    fn consider_new_system_with_perturb_always_cd_seeds_dcd() {
968        let mut h = PdPerturbationHandler::new();
969        h.set_perturb_always_cd(true);
970        let mu = 0.1;
971        let d = h.consider_new_system(mu, None).unwrap();
972        let expected = h.delta_cd_val * mu.powf(h.delta_cd_exp);
973        assert!((d.delta_c - expected).abs() < 1e-15);
974        assert!((d.delta_d - expected).abs() < 1e-15);
975        assert_eq!(d.delta_x, 0.0);
976        assert_eq!(d.delta_s, 0.0);
977    }
978
979    #[test]
980    fn consider_new_system_default_zeros_dcd() {
981        let mut h = PdPerturbationHandler::new();
982        let d = h.consider_new_system(0.1, None).unwrap();
983        assert_eq!(
984            d,
985            Deltas {
986                delta_x: 0.0,
987                delta_s: 0.0,
988                delta_c: 0.0,
989                delta_d: 0.0
990            }
991        );
992    }
993
994    #[test]
995    fn singular_in_test_dc_eq0_dx_eq0_seeds_dcd() {
996        let mut h = PdPerturbationHandler::new();
997        let _ = h.consider_new_system(0.1, None).unwrap();
998        // After consider_new_system on a fresh handler, test_status
999        // should be DcEq0DxEq0 (since both flags are NotYetDetermined,
1000        // and perturb_always_cd is false).
1001        assert_eq!(h.test_status, TrialStatus::DcEq0DxEq0);
1002        let d = h.perturb_for_singular(0.1, None).unwrap();
1003        let expected = h.delta_cd_val * (0.1_f64).powf(h.delta_cd_exp);
1004        assert!((d.delta_c - expected).abs() < 1e-15);
1005        assert!((d.delta_d - expected).abs() < 1e-15);
1006        assert_eq!(d.delta_x, 0.0);
1007        assert_eq!(h.test_status, TrialStatus::DcGt0DxEq0);
1008    }
1009
1010    #[test]
1011    fn singular_when_determined_with_dc_zero_seeds_dcd() {
1012        let mut h = PdPerturbationHandler::new();
1013        h.hess_degenerate = DegenType::NotDegenerate;
1014        h.jac_degenerate = DegenType::NotDegenerate;
1015        h.test_status = TrialStatus::NoTest;
1016        let d = h.perturb_for_singular(0.1, None).unwrap();
1017        let expected = h.delta_cd_val * (0.1_f64).powf(h.delta_cd_exp);
1018        assert!((d.delta_c - expected).abs() < 1e-15);
1019    }
1020
1021    /// gh#540: the case upstream's `DBG_ASSERT`s actually trip on. Reach
1022    /// `perturb_for_singular` a *second* time, from a rung of the δ_x ladder,
1023    /// while the Jacobian flag is still undetermined — so `finalize_test` has
1024    /// not yet resolved it and the `DcGt0DxEq0` arm is entered with
1025    /// `δ_x > 0`, against its `debug_assert!(delta_x_curr == 0.0 && ...)`.
1026    /// Every real linear solver can produce this sequence (MUMPS
1027    /// `INFO(1) = -10`, MA27 `IFLAG = 3`, and pounce's inertia-trust floor
1028    /// all report singularity from anywhere on the ladder), so the
1029    /// precondition is an assumption rather than an invariant, and the
1030    /// handler has to survive it rather than assert it.
1031    #[test]
1032    fn a_second_singular_verdict_from_the_ladder_does_not_trip_the_probe() {
1033        let mut h = PdPerturbationHandler::new();
1034        let _ = h.consider_new_system(0.1, None).unwrap();
1035        // Singular at δ_x = 0 → the probe raises δ_c and moves to DcGt0DxEq0,
1036        // leaving the Jacobian flag undetermined.
1037        let _ = h.perturb_for_singular(0.1, None).unwrap();
1038        assert_eq!(h.test_status, TrialStatus::DcGt0DxEq0);
1039        assert_eq!(h.jac_degenerate, DegenType::NotYetDetermined);
1040        // WrongInertia next → δ_x leaves zero. `finalize_test` resolves the
1041        // Hessian flag but leaves the Jacobian one undetermined (it needs
1042        // `degen_iters_max` trials), so the probe is still nominally running.
1043        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
1044        assert!(d.delta_x > 0.0);
1045        assert_eq!(h.jac_degenerate, DegenType::NotYetDetermined);
1046        assert_eq!(h.test_status, TrialStatus::DcGt0DxEq0);
1047        // ...and now Singular again, with δ_x > 0 under a `DxEq0` status.
1048        // Pre-#540 this is the `debug_assert` that fires.
1049        let d = h
1050            .perturb_for_singular(0.1, None)
1051            .expect("a singular verdict from the ladder must not be fatal");
1052        assert!(
1053            d.delta_c > 0.0,
1054            "δ_c was dropped by the abandoned probe: {}",
1055            d.delta_c,
1056        );
1057        assert_eq!(
1058            h.test_status,
1059            TrialStatus::NoTest,
1060            "a probe whose precondition no longer holds must be abandoned",
1061        );
1062    }
1063
1064    /// The same guard on the commoner sequence, where `finalize_test` has
1065    /// already resolved both flags by the time the `Singular` verdict lands:
1066    /// δ_c comes up and the δ_x rung the ladder already paid for is kept.
1067    #[test]
1068    fn singular_after_a_delta_x_step_abandons_the_probe() {
1069        let mut h = PdPerturbationHandler::new();
1070        // Fresh system: both flags undetermined, so the probe arms as
1071        // DcEq0DxEq0 with δ_x = δ_c = 0.
1072        let _ = h.consider_new_system(0.1, None).unwrap();
1073        assert_eq!(h.test_status, TrialStatus::DcEq0DxEq0);
1074        // First factorization came back WrongInertia — δ_x leaves zero while
1075        // `test_status` still says the probe is running at δ_x = 0.
1076        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
1077        assert!(d.delta_x > 0.0);
1078        // Second factorization comes back Singular.
1079        let d = h.perturb_for_singular(0.1, None).unwrap();
1080        let expected_cd = h.delta_cd_val * (0.1_f64).powf(h.delta_cd_exp);
1081        assert!(
1082            (d.delta_c - expected_cd).abs() < 1e-20,
1083            "δ_c was not raised: {}",
1084            d.delta_c,
1085        );
1086        assert_eq!(d.delta_c, d.delta_d);
1087        assert_eq!(
1088            d.delta_x, h.delta_xs_init,
1089            "the δ_x ladder lost the rung it had already paid for",
1090        );
1091        assert_eq!(
1092            h.test_status,
1093            TrialStatus::NoTest,
1094            "a probe whose precondition no longer holds must be abandoned, \
1095             not carried into finalize_test",
1096        );
1097    }
1098
1099    #[test]
1100    fn finalize_test_sets_not_degenerate_after_dc_eq0_dx_eq0_pass() {
1101        let mut h = PdPerturbationHandler::new();
1102        let _ = h.consider_new_system(0.1, None).unwrap();
1103        // Simulate the next call recognizing that the previous trial
1104        // factor was non-singular: a fresh consider_new_system runs
1105        // finalize_test first.
1106        let _ = h.consider_new_system(0.1, None).unwrap();
1107        assert_eq!(h.hess_degenerate, DegenType::NotDegenerate);
1108        assert_eq!(h.jac_degenerate, DegenType::NotDegenerate);
1109    }
1110
1111    #[test]
1112    fn current_perturbation_returns_committed_values() {
1113        let mut h = PdPerturbationHandler::new();
1114        h.delta_x_curr = 1.0;
1115        h.delta_s_curr = 2.0;
1116        h.delta_c_curr = 3.0;
1117        h.delta_d_curr = 4.0;
1118        let d = h.current_perturbation();
1119        assert_eq!(d.delta_x, 1.0);
1120        assert_eq!(d.delta_s, 2.0);
1121        assert_eq!(d.delta_c, 3.0);
1122        assert_eq!(d.delta_d, 4.0);
1123    }
1124
1125    /// Drive the handler to a gh#592 withdrawal: `δ_c` up on a `Singular`,
1126    /// then three `WrongInertia` rungs under it. Returns the rung `δ_c`
1127    /// was withdrawn at.
1128    fn withdraw(h: &mut PdPerturbationHandler) -> Number {
1129        h.consider_new_system(0.1, None).unwrap();
1130        h.perturb_for_singular(0.1, None).unwrap();
1131        let mut at = 0.0;
1132        for _ in 0..3 {
1133            at = h.delta_x_curr;
1134            h.perturb_for_wrong_inertia(0.1, None).unwrap();
1135        }
1136        assert!(h.delta_c_abandoned, "precondition: δ_c was withdrawn");
1137        assert_eq!(h.delta_x_at_withdrawal, at);
1138        at
1139    }
1140
1141    /// gh#981: the first factorization without `δ_c` comes back with a
1142    /// zero pivot the solver found. Only `δ_c` changed between that
1143    /// factor and the nonsingular one at the withdrawal rung, so the
1144    /// Jacobian is rank-deficient and `δ_c` goes back — with the ladder
1145    /// resumed from the withdrawal rung, not restarted below it. On the
1146    /// gh#981 CSTR this is the difference between a peak `δ_w` of
1147    /// `2.1e-3` and one of `1.09`.
1148    #[test]
1149    fn a_certified_singular_from_the_first_delta_c_free_factor_reinstates_delta_c() {
1150        let mut h = PdPerturbationHandler::new();
1151        let at = withdraw(&mut h);
1152        let d = h.perturb_for_singular_with(0.1, true, None).unwrap();
1153        assert!(d.delta_c > 0.0, "δ_c was not reinstated");
1154        assert_eq!(d.delta_d, d.delta_c);
1155        assert!(
1156            d.delta_x > at,
1157            "the ladder restarted below the withdrawal rung ({} <= {at})",
1158            d.delta_x
1159        );
1160        assert!(h.delta_c_reinstated && !h.delta_c_abandoned);
1161
1162        // And it stays: the walk-back does not fire a second time in this
1163        // aug-system, however many rungs follow.
1164        for _ in 0..6 {
1165            let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
1166            assert!(d.delta_c > 0.0, "δ_c withdrawn a second time");
1167        }
1168        // A new aug-system starts clean.
1169        h.consider_new_system(0.1, None).unwrap();
1170        assert!(!h.delta_c_reinstated && !h.withdrawal_untested);
1171    }
1172
1173    /// gh#981: an *uncertified* `Singular` — gh#540's untrusted-inertia
1174    /// report — is the evidence the walk-back exists for, and does not
1175    /// reinstate. This is the shape of every post-withdrawal `Singular`
1176    /// on gh#884's `mpcc_qpec_small_biactive`, where reinstating costs the
1177    /// certificate.
1178    #[test]
1179    fn an_uncertified_singular_does_not_reinstate_delta_c() {
1180        let mut h = PdPerturbationHandler::new();
1181        withdraw(&mut h);
1182        for _ in 0..5 {
1183            let d = h.perturb_for_singular_with(0.1, false, None).unwrap();
1184            assert_eq!(d.delta_c, 0.0, "δ_c came back on an untrusted report");
1185            assert!(d.delta_x > 0.0);
1186        }
1187        assert!(!h.delta_c_reinstated);
1188    }
1189
1190    /// gh#981: only the *first* `δ_c`-free factor can refute the
1191    /// withdrawal. A zero pivot that appears several rungs up a climb
1192    /// whose first rungs were nonsingular is round-off, not a dependent
1193    /// row — measured on `pooling_rt2stp`, where honouring it moved the
1194    /// exact leg onto a different local optimum.
1195    #[test]
1196    fn a_certified_singular_later_in_the_climb_does_not_reinstate_delta_c() {
1197        let mut h = PdPerturbationHandler::new();
1198        withdraw(&mut h);
1199        // First δ_c-free factor: `WrongInertia` — gh#592's full-rank case.
1200        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
1201        assert_eq!(d.delta_c, 0.0);
1202        assert!(!h.withdrawal_untested);
1203        // A certified `Singular` two rungs later is too late.
1204        h.perturb_for_singular_with(0.1, false, None).unwrap();
1205        let d = h.perturb_for_singular_with(0.1, true, None).unwrap();
1206        assert_eq!(d.delta_c, 0.0, "a late zero pivot reinstated δ_c");
1207        assert!(!h.delta_c_reinstated);
1208    }
1209
1210    /// gh#981: an uncertified first report also closes the window, so a
1211    /// certified one right after it does not reinstate either.
1212    #[test]
1213    fn an_uncertified_first_report_closes_the_window() {
1214        let mut h = PdPerturbationHandler::new();
1215        withdraw(&mut h);
1216        h.perturb_for_singular_with(0.1, false, None).unwrap();
1217        let d = h.perturb_for_singular_with(0.1, true, None).unwrap();
1218        assert_eq!(d.delta_c, 0.0);
1219    }
1220
1221    /// gh#981: before any withdrawal a certified `Singular` is the
1222    /// ordinary `perturb_for_singular` path — `δ_c` up, nothing latched.
1223    #[test]
1224    fn a_certified_singular_before_any_withdrawal_is_the_ordinary_path() {
1225        let mut h = PdPerturbationHandler::new();
1226        h.consider_new_system(0.1, None).unwrap();
1227        let a = h.perturb_for_singular_with(0.1, true, None).unwrap();
1228        let mut g = PdPerturbationHandler::new();
1229        g.consider_new_system(0.1, None).unwrap();
1230        let b = g.perturb_for_singular(0.1, None).unwrap();
1231        assert_eq!(a, b);
1232        assert!(!h.delta_c_reinstated && !h.delta_c_abandoned);
1233    }
1234}