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