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}
119
120impl Default for PdPerturbationHandler {
121    fn default() -> Self {
122        Self {
123            delta_xs_max: 1e20,
124            delta_xs_min: 1e-20,
125            delta_xs_first_inc_fact: 100.0,
126            delta_xs_inc_fact: 8.0,
127            delta_xs_dec_fact: 1.0 / 3.0,
128            delta_xs_init: 1e-4,
129            delta_cd_val: 1e-8,
130            delta_cd_exp: 0.25,
131            perturb_always_cd: false,
132            reset_last: false,
133            degen_iters_max: 3,
134            delta_x_curr: 0.0,
135            delta_s_curr: 0.0,
136            delta_c_curr: 0.0,
137            delta_d_curr: 0.0,
138            delta_x_last: 0.0,
139            delta_s_last: 0.0,
140            delta_c_last: 0.0,
141            delta_d_last: 0.0,
142            get_deltas_for_wrong_inertia_called: false,
143            hess_degenerate: DegenType::NotYetDetermined,
144            jac_degenerate: DegenType::NotYetDetermined,
145            degen_iters: 0,
146            test_status: TrialStatus::NoTest,
147            // Three rungs is above anything the models δ_c exists for
148            // ever need: on eigena2 and eigenb2 (the gh#540 / gh#544
149            // motivating cases) δ_c is followed by at most one rung
150            // before the factor is accepted. See the doc comment on
151            // `perturb_for_wrong_inertia`.
152            delta_c_rungs: 0,
153            delta_c_abandoned: false,
154            delta_c_max_rungs: 3,
155        }
156    }
157}
158
159/// Snapshot of the four perturbations after a state-machine call.
160#[derive(Debug, Clone, Copy, PartialEq)]
161pub struct Deltas {
162    pub delta_x: Number,
163    pub delta_s: Number,
164    pub delta_c: Number,
165    pub delta_d: Number,
166}
167
168impl PdPerturbationHandler {
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    /// Configure `perturb_always_cd_` and rebuild the initial `jac`
174    /// state. Mirrors upstream's `InitializeImpl`.
175    pub fn set_perturb_always_cd(&mut self, on: bool) {
176        self.perturb_always_cd = on;
177        self.jac_degenerate = if on {
178            DegenType::NotDegenerate
179        } else {
180            DegenType::NotYetDetermined
181        };
182    }
183
184    /// First call when starting a new aug-system. `mu` is the current
185    /// barrier parameter (used by the `δ_cd` formula).
186    /// Returns `None` if no suitable starting perturbation could be
187    /// found (the caller bails).
188    pub fn consider_new_system(
189        &mut self,
190        mu: Number,
191        ip_data: Option<&dyn PerturbationSink>,
192    ) -> Option<Deltas> {
193        self.finalize_test(ip_data);
194
195        // Bookkeeping: roll the previous trial's `_curr` values into
196        // `_last` (matches upstream cpp:158-183).
197        if self.reset_last {
198            self.delta_x_last = self.delta_x_curr;
199            self.delta_s_last = self.delta_s_curr;
200            self.delta_c_last = self.delta_c_curr;
201            self.delta_d_last = self.delta_d_curr;
202        } else {
203            if self.delta_x_curr > 0.0 {
204                self.delta_x_last = self.delta_x_curr;
205            }
206            if self.delta_s_curr > 0.0 {
207                self.delta_s_last = self.delta_s_curr;
208            }
209            if self.delta_c_curr > 0.0 {
210                self.delta_c_last = self.delta_c_curr;
211            }
212            if self.delta_d_curr > 0.0 {
213                self.delta_d_last = self.delta_d_curr;
214            }
215        }
216
217        let undet = matches!(self.hess_degenerate, DegenType::NotYetDetermined)
218            || matches!(self.jac_degenerate, DegenType::NotYetDetermined);
219        self.test_status = if undet {
220            if self.perturb_always_cd {
221                TrialStatus::DcGt0DxEq0
222            } else {
223                TrialStatus::DcEq0DxEq0
224            }
225        } else {
226            TrialStatus::NoTest
227        };
228
229        let mut delta_c = if matches!(self.jac_degenerate, DegenType::Degenerate) {
230            let v = self.delta_cd(mu);
231            self.delta_c_curr = v;
232            append_info(ip_data, "l");
233            v
234        } else if self.perturb_always_cd {
235            let v = self.delta_cd(mu);
236            self.delta_c_curr = v;
237            v
238        } else {
239            self.delta_c_curr = 0.0;
240            0.0
241        };
242        let mut delta_d = delta_c;
243        self.delta_d_curr = delta_d;
244
245        let mut delta_x = 0.0;
246        let mut delta_s = 0.0;
247
248        if matches!(self.hess_degenerate, DegenType::Degenerate) {
249            self.delta_x_curr = 0.0;
250            self.delta_s_curr = 0.0;
251            if !self.get_deltas_for_wrong_inertia(
252                &mut delta_x,
253                &mut delta_s,
254                &mut delta_c,
255                &mut delta_d,
256                ip_data,
257            ) {
258                return None;
259            }
260        }
261
262        self.delta_x_curr = delta_x;
263        self.delta_s_curr = delta_s;
264        self.delta_c_curr = delta_c;
265        self.delta_d_curr = delta_d;
266        set_info_regu_x(ip_data, delta_x);
267        self.get_deltas_for_wrong_inertia_called = false;
268        // gh#592: the walk-back is scoped to a single aug-system.
269        self.delta_c_rungs = 0;
270        self.delta_c_abandoned = false;
271
272        Some(Deltas {
273            delta_x,
274            delta_s,
275            delta_c,
276            delta_d,
277        })
278    }
279
280    /// Escalation after `Singular` factorization status. Mirrors
281    /// `PerturbForSingularity` (cpp:245-364).
282    pub fn perturb_for_singular(
283        &mut self,
284        mu: Number,
285        ip_data: Option<&dyn PerturbationSink>,
286    ) -> Option<Deltas> {
287        let mut delta_x = 0.0;
288        let mut delta_s = 0.0;
289        let mut delta_c = 0.0;
290        let mut delta_d = 0.0;
291
292        // gh#592: `δ_c` has already been tried and withdrawn for this
293        // aug-system (see `maybe_withdraw_delta_c`). A further
294        // `Singular` is the same evidence that did not respond to it,
295        // so answer it on the `δ_x` ladder rather than putting `δ_c`
296        // straight back — which is what the arms below would do, and
297        // would cycle.
298        if self.delta_c_abandoned {
299            self.test_status = TrialStatus::NoTest;
300            if !self.get_deltas_for_wrong_inertia(
301                &mut delta_x,
302                &mut delta_s,
303                &mut delta_c,
304                &mut delta_d,
305                ip_data,
306            ) {
307                return None;
308            }
309            set_info_regu_x(ip_data, self.delta_x_curr);
310            return Some(Deltas {
311                delta_x: self.delta_x_curr,
312                delta_s: self.delta_s_curr,
313                delta_c: self.delta_c_curr,
314                delta_d: self.delta_d_curr,
315            });
316        }
317
318        // Upstream's `TrialStatus` arms below assert that the degeneracy
319        // probe is still in the state that named it — `δ_x == 0` for the
320        // `DxEq0` statuses, and so on. Those are `DBG_ASSERT`s upstream,
321        // i.e. assumptions rather than invariants: every real linear
322        // solver can report `Singular` from *any* rung of the δ_x ladder
323        // (MUMPS `INFO(1) = -10`, MA27 `IFLAG = 3`, and — since pounce
324        // gh#540 — feral's inertia-trust floor), at which point the probe
325        // has already been disturbed and its arm no longer applies.
326        // Abandon the probe and fall through to the determined-state path,
327        // which asserts nothing and does the right thing from wherever the
328        // perturbations happen to be: raise δ_c if it is still zero,
329        // otherwise take a δ_x step.
330        let probe_intact = match self.test_status {
331            TrialStatus::DcEq0DxEq0 => self.delta_x_curr == 0.0 && self.delta_c_curr == 0.0,
332            TrialStatus::DcGt0DxEq0 => self.delta_x_curr == 0.0 && self.delta_c_curr > 0.0,
333            TrialStatus::DcEq0DxGt0 => self.delta_x_curr > 0.0 && self.delta_c_curr == 0.0,
334            TrialStatus::DcGt0DxGt0 | TrialStatus::NoTest => true,
335        };
336        if !probe_intact {
337            self.test_status = TrialStatus::NoTest;
338        }
339
340        let undet = probe_intact
341            && (matches!(self.hess_degenerate, DegenType::NotYetDetermined)
342                || matches!(self.jac_degenerate, DegenType::NotYetDetermined));
343        if undet {
344            match self.test_status {
345                TrialStatus::DcEq0DxEq0 => {
346                    debug_assert!(self.delta_x_curr == 0.0 && self.delta_c_curr == 0.0);
347                    if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
348                        let v = self.delta_cd(mu);
349                        self.delta_c_curr = v;
350                        self.delta_d_curr = v;
351                        self.test_status = TrialStatus::DcGt0DxEq0;
352                    } else {
353                        debug_assert!(matches!(self.hess_degenerate, DegenType::NotYetDetermined));
354                        if !self.get_deltas_for_wrong_inertia(
355                            &mut delta_x,
356                            &mut delta_s,
357                            &mut delta_c,
358                            &mut delta_d,
359                            ip_data,
360                        ) {
361                            return None;
362                        }
363                        self.test_status = TrialStatus::DcEq0DxGt0;
364                    }
365                }
366                TrialStatus::DcGt0DxEq0 => {
367                    debug_assert!(self.delta_x_curr == 0.0 && self.delta_c_curr > 0.0);
368                    debug_assert!(matches!(self.jac_degenerate, DegenType::NotYetDetermined));
369                    if !self.perturb_always_cd {
370                        self.delta_c_curr = 0.0;
371                        self.delta_d_curr = 0.0;
372                        if !self.get_deltas_for_wrong_inertia(
373                            &mut delta_x,
374                            &mut delta_s,
375                            &mut delta_c,
376                            &mut delta_d,
377                            ip_data,
378                        ) {
379                            return None;
380                        }
381                        self.test_status = TrialStatus::DcEq0DxGt0;
382                    } else 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                    } else {
391                        self.test_status = TrialStatus::DcGt0DxGt0;
392                    }
393                }
394                TrialStatus::DcEq0DxGt0 => {
395                    debug_assert!(self.delta_x_curr > 0.0 && self.delta_c_curr == 0.0);
396                    let v = self.delta_cd(mu);
397                    self.delta_c_curr = v;
398                    self.delta_d_curr = v;
399                    if !self.get_deltas_for_wrong_inertia(
400                        &mut delta_x,
401                        &mut delta_s,
402                        &mut delta_c,
403                        &mut delta_d,
404                        ip_data,
405                    ) {
406                        return None;
407                    }
408                    self.test_status = TrialStatus::DcGt0DxGt0;
409                }
410                TrialStatus::DcGt0DxGt0 => {
411                    if !self.get_deltas_for_wrong_inertia(
412                        &mut delta_x,
413                        &mut delta_s,
414                        &mut delta_c,
415                        &mut delta_d,
416                        ip_data,
417                    ) {
418                        return None;
419                    }
420                }
421                TrialStatus::NoTest => {
422                    debug_assert!(false, "perturb_for_singular: NoTest in undetermined branch");
423                }
424            }
425        } else if self.delta_c_curr > 0.0 {
426            // Already perturbed C; treat as wrong-inertia.
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        } else {
437            let v = self.delta_cd(mu);
438            self.delta_c_curr = v;
439            self.delta_d_curr = v;
440            append_info(ip_data, "L");
441        }
442
443        let out = Deltas {
444            delta_x: self.delta_x_curr,
445            delta_s: self.delta_s_curr,
446            delta_c: self.delta_c_curr,
447            delta_d: self.delta_d_curr,
448        };
449        set_info_regu_x(ip_data, out.delta_x);
450        Some(out)
451    }
452
453    /// Escalation after `WrongInertia` factorization status. Mirrors
454    /// `PerturbForWrongInertia` (cpp:419-450).
455    pub fn perturb_for_wrong_inertia(
456        &mut self,
457        mu: Number,
458        ip_data: Option<&dyn PerturbationSink>,
459    ) -> Option<Deltas> {
460        if std::env::var_os("POUNCE_DBG_PERT").is_some() {
461            if let Some(d) = ip_data {
462                d.debug(&format!(
463                    "[PERT] iter={} WRONG_INERTIA mu={:.2e} dx_last={:.2e} dx_curr={:.2e}",
464                    d.iter_count(),
465                    mu,
466                    self.delta_x_last,
467                    self.delta_x_curr
468                ));
469            }
470        }
471        self.finalize_test(ip_data);
472        self.maybe_withdraw_delta_c(ip_data);
473
474        let mut delta_x = 0.0;
475        let mut delta_s = 0.0;
476        let mut delta_c = 0.0;
477        let mut delta_d = 0.0;
478        let mut ok = self.get_deltas_for_wrong_inertia(
479            &mut delta_x,
480            &mut delta_s,
481            &mut delta_c,
482            &mut delta_d,
483            ip_data,
484        );
485        // Upstream "no progress on δ_x but δ_c == 0" recovery: bring
486        // up the C/D perturbation, reset Hessian degeneracy, retry.
487        // Upstream peeks at the OUT-parameter `delta_c`, but
488        // `get_deltas_for_wrong_inertia` only writes that on success;
489        // we look at the handler's own δ_c_curr instead, which
490        // matches the algorithmic intent unambiguously.
491        if !ok && self.delta_c_curr == 0.0 {
492            debug_assert_eq!(self.delta_d_curr, 0.0);
493            let v = self.delta_cd(mu);
494            self.delta_c_curr = v;
495            self.delta_d_curr = v;
496            self.delta_x_curr = 0.0;
497            self.delta_s_curr = 0.0;
498            self.test_status = TrialStatus::NoTest;
499            if matches!(self.hess_degenerate, DegenType::Degenerate) {
500                self.hess_degenerate = DegenType::NotYetDetermined;
501            }
502            ok = self.get_deltas_for_wrong_inertia(
503                &mut delta_x,
504                &mut delta_s,
505                &mut delta_c,
506                &mut delta_d,
507                ip_data,
508            );
509        }
510        if !ok {
511            return None;
512        }
513        Some(Deltas {
514            delta_x,
515            delta_s,
516            delta_c,
517            delta_d,
518        })
519    }
520
521    /// gh#592: withdraw `δ_c` once it has demonstrably failed to buy a
522    /// usable inertia.
523    ///
524    /// `δ_c` is the perturbation for a rank-deficient constraint
525    /// Jacobian, and it is reached for when the factorization reports
526    /// `Singular`. Since gh#540 a factorization also reports `Singular`
527    /// when its inertia is *unmeasurable* — the count disagrees and the
528    /// smallest pivot is at the noise floor — which is evidence about
529    /// the measurement, not about the Jacobian's rank. When the
530    /// Jacobian in fact has full rank, `δ_c` cannot help, and because it
531    /// stays switched on for the rest of the aug-system the `δ_x` ladder
532    /// then has to climb against a matrix `δ_c` has made *harder* to hit
533    /// the requested inertia on: on the gh#592 model this cost five
534    /// rungs, ending at `δ_w = 1e2` where Ipopt accepted the step at
535    /// `1e-4`, and the over-damped step froze the objective for the next
536    /// eight iterations before the loose-tolerance exit test fired.
537    ///
538    /// So rather than predict which kind of `Singular` this was — the
539    /// counts are the very thing gh#540 established are noise — let the
540    /// ladder answer it. After `delta_c_max_rungs` rungs with `δ_c` on
541    /// and still no acceptable inertia, `δ_c` has had its chance:
542    /// withdraw it, restart the `δ_x` ladder, and latch it off for the
543    /// remainder of this aug-system. Where `δ_c` is the right remedy
544    /// this never fires — on eigena2 and eigenb2 it is followed by at
545    /// most one rung.
546    fn maybe_withdraw_delta_c(&mut self, ip_data: Option<&dyn PerturbationSink>) {
547        if self.delta_c_max_rungs <= 0 || self.delta_c_abandoned || self.delta_c_curr <= 0.0 {
548            return;
549        }
550        self.delta_c_rungs += 1;
551        if self.delta_c_rungs < self.delta_c_max_rungs {
552            return;
553        }
554        self.delta_c_curr = 0.0;
555        self.delta_d_curr = 0.0;
556        self.delta_x_curr = 0.0;
557        self.delta_s_curr = 0.0;
558        self.delta_c_abandoned = true;
559        self.test_status = TrialStatus::NoTest;
560        append_info(ip_data, "w");
561    }
562
563    /// Read the most recently committed perturbations.
564    pub fn current_perturbation(&self) -> Deltas {
565        Deltas {
566            delta_x: self.delta_x_curr,
567            delta_s: self.delta_s_curr,
568            delta_c: self.delta_c_curr,
569            delta_d: self.delta_d_curr,
570        }
571    }
572
573    /// Internal — pure escalation of `δ_x` / `δ_s`. Returns `false` if
574    /// `δ_x` would exceed `delta_xs_max`. Mirrors
575    /// `get_deltas_for_wrong_inertia`.
576    fn get_deltas_for_wrong_inertia(
577        &mut self,
578        delta_x: &mut Number,
579        delta_s: &mut Number,
580        delta_c: &mut Number,
581        delta_d: &mut Number,
582        ip_data: Option<&dyn PerturbationSink>,
583    ) -> bool {
584        if self.delta_x_curr == 0.0 {
585            self.delta_x_curr = if self.delta_x_last == 0.0 {
586                self.delta_xs_init
587            } else {
588                self.delta_xs_min
589                    .max(self.delta_x_last * self.delta_xs_dec_fact)
590            };
591        } else if self.delta_x_last == 0.0 || 1e5 * self.delta_x_last < self.delta_x_curr {
592            self.delta_x_curr *= self.delta_xs_first_inc_fact;
593        } else {
594            self.delta_x_curr *= self.delta_xs_inc_fact;
595        }
596        if self.delta_x_curr > self.delta_xs_max {
597            self.delta_x_last = 0.0;
598            self.delta_s_last = 0.0;
599            append_info(ip_data, "dx");
600            return false;
601        }
602        self.delta_s_curr = self.delta_x_curr;
603
604        *delta_x = self.delta_x_curr;
605        *delta_s = self.delta_s_curr;
606        *delta_c = self.delta_c_curr;
607        *delta_d = self.delta_d_curr;
608        set_info_regu_x(ip_data, *delta_x);
609        self.get_deltas_for_wrong_inertia_called = true;
610        true
611    }
612
613    fn delta_cd(&self, mu: Number) -> Number {
614        self.delta_cd_val * mu.powf(self.delta_cd_exp)
615    }
616
617    /// Read the test outcome from the just-completed (non-singular)
618    /// factor and update degeneracy flags. Mirrors `finalize_test`
619    /// (cpp:470-538).
620    fn finalize_test(&mut self, ip_data: Option<&dyn PerturbationSink>) {
621        match self.test_status {
622            TrialStatus::NoTest => (),
623            TrialStatus::DcEq0DxEq0 => {
624                if matches!(self.hess_degenerate, DegenType::NotYetDetermined)
625                    && matches!(self.jac_degenerate, DegenType::NotYetDetermined)
626                {
627                    self.hess_degenerate = DegenType::NotDegenerate;
628                    self.jac_degenerate = DegenType::NotDegenerate;
629                    append_info(ip_data, "Nhj ");
630                } else if matches!(self.hess_degenerate, DegenType::NotYetDetermined) {
631                    self.hess_degenerate = DegenType::NotDegenerate;
632                    append_info(ip_data, "Nh ");
633                } else if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
634                    self.jac_degenerate = DegenType::NotDegenerate;
635                    append_info(ip_data, "Nj ");
636                }
637            }
638            TrialStatus::DcGt0DxEq0 => {
639                if matches!(self.hess_degenerate, DegenType::NotYetDetermined) {
640                    self.hess_degenerate = DegenType::NotDegenerate;
641                    append_info(ip_data, "Nh ");
642                }
643                if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
644                    self.degen_iters += 1;
645                    if self.degen_iters >= self.degen_iters_max {
646                        self.jac_degenerate = DegenType::Degenerate;
647                        append_info(ip_data, "Dj ");
648                    }
649                    append_info(ip_data, "L");
650                }
651            }
652            TrialStatus::DcEq0DxGt0 => {
653                if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
654                    self.jac_degenerate = DegenType::NotDegenerate;
655                    append_info(ip_data, "Nj ");
656                }
657                if matches!(self.hess_degenerate, DegenType::NotYetDetermined) {
658                    self.degen_iters += 1;
659                    if self.degen_iters >= self.degen_iters_max {
660                        self.hess_degenerate = DegenType::Degenerate;
661                        append_info(ip_data, "Dh ");
662                    }
663                }
664            }
665            TrialStatus::DcGt0DxGt0 => {
666                self.degen_iters += 1;
667                if self.degen_iters >= self.degen_iters_max {
668                    self.hess_degenerate = DegenType::Degenerate;
669                    self.jac_degenerate = DegenType::Degenerate;
670                    append_info(ip_data, "Dhj ");
671                }
672                append_info(ip_data, "L");
673            }
674        }
675    }
676}
677
678fn append_info(sink: Option<&dyn PerturbationSink>, s: &str) {
679    if let Some(h) = sink {
680        h.append_info(s);
681    }
682}
683
684fn set_info_regu_x(sink: Option<&dyn PerturbationSink>, v: Number) {
685    if let Some(h) = sink {
686        h.set_regu_x(v);
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    /// gh#592: three rungs of the `δ_x` ladder with `δ_c` on and still
695    /// no acceptable inertia is `δ_c` failing at the job it was raised
696    /// for, so it is withdrawn and the ladder restarts without it.
697    #[test]
698    fn delta_c_is_withdrawn_after_the_ladder_has_climbed_without_it() {
699        let mut h = PdPerturbationHandler::new();
700        h.consider_new_system(0.1, None).unwrap();
701
702        // A `Singular` factor raises δ_c on its own — no δ_x yet.
703        let d = h.perturb_for_singular(0.1, None).unwrap();
704        assert!(d.delta_c > 0.0, "δ_c should be the first response");
705        assert_eq!(d.delta_x, 0.0);
706
707        // Rungs one and two: δ_c stays, because it may yet pay off.
708        for rung in 1..=2 {
709            let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
710            assert!(d.delta_c > 0.0, "δ_c withdrawn early, at rung {rung}");
711            assert!(d.delta_x > 0.0);
712        }
713
714        // Rung three: withdrawn, and the ladder restarts from the low
715        // rung rather than carrying the height it reached under δ_c.
716        let before = h.delta_x_curr;
717        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
718        assert_eq!(d.delta_c, 0.0, "δ_c was not withdrawn at the third rung");
719        assert_eq!(d.delta_d, 0.0);
720        assert!(
721            d.delta_x > 0.0 && d.delta_x < before,
722            "the ladder did not restart"
723        );
724    }
725
726    /// ...and it stays withdrawn for the rest of this aug-system. The
727    /// factorization that reported `Singular` will keep reporting it for
728    /// the same reason, so without the latch δ_c would go straight back
729    /// on and the walk-back would cycle.
730    #[test]
731    fn a_withdrawn_delta_c_is_not_raised_again_until_the_next_iterate() {
732        let mut h = PdPerturbationHandler::new();
733        h.consider_new_system(0.1, None).unwrap();
734        h.perturb_for_singular(0.1, None).unwrap();
735        for _ in 0..3 {
736            h.perturb_for_wrong_inertia(0.1, None).unwrap();
737        }
738        assert!(h.delta_c_abandoned);
739
740        let d = h.perturb_for_singular(0.1, None).unwrap();
741        assert_eq!(d.delta_c, 0.0, "δ_c came back inside the same aug-system");
742        assert!(d.delta_x > 0.0, "the `Singular` was not answered at all");
743
744        // The next iterate starts clean: the withdrawal is a statement
745        // about one aug-system, not about the problem.
746        h.consider_new_system(0.1, None).unwrap();
747        assert!(!h.delta_c_abandoned);
748        assert_eq!(h.delta_c_rungs, 0);
749        let d = h.perturb_for_singular(0.1, None).unwrap();
750        assert!(d.delta_c > 0.0, "δ_c is still latched off a new aug-system");
751    }
752
753    /// Where `δ_c` is the right remedy the walk-back must be invisible.
754    /// One rung is the whole pattern on eigena2 and eigenb2, the models
755    /// gh#540 / gh#544 raised δ_c for.
756    #[test]
757    fn one_rung_under_delta_c_leaves_it_alone() {
758        let mut h = PdPerturbationHandler::new();
759        h.consider_new_system(0.1, None).unwrap();
760        h.perturb_for_singular(0.1, None).unwrap();
761        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
762        assert!(d.delta_c > 0.0);
763        // A good factor ends the aug-system; the counter resets with it.
764        h.consider_new_system(0.1, None).unwrap();
765        assert_eq!(h.delta_c_rungs, 0);
766    }
767
768    /// The opt-out restores the pre-#592 escalation exactly: δ_c on,
769    /// and the δ_x ladder climbing against it without bound.
770    #[test]
771    fn zero_max_rungs_disables_the_walkback() {
772        let mut h = PdPerturbationHandler::new();
773        h.delta_c_max_rungs = 0;
774        h.consider_new_system(0.1, None).unwrap();
775        h.perturb_for_singular(0.1, None).unwrap();
776        let mut last = 0.0;
777        for _ in 0..6 {
778            let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
779            assert!(d.delta_c > 0.0, "δ_c was withdrawn with the walk-back off");
780            assert!(d.delta_x > last, "the ladder stopped climbing");
781            last = d.delta_x;
782        }
783        assert!(!h.delta_c_abandoned);
784    }
785
786    #[test]
787    fn first_wrong_inertia_perturbation_is_delta_xs_init() {
788        let mut h = PdPerturbationHandler::new();
789        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
790        // delta_xs_init = first_hessian_perturbation = 1e-4
791        assert!((d.delta_x - 1e-4).abs() < 1e-20);
792        assert_eq!(d.delta_x, d.delta_s);
793        assert_eq!(d.delta_c, 0.0);
794        assert_eq!(d.delta_d, 0.0);
795    }
796
797    #[test]
798    fn second_perturbation_uses_first_inc_fact() {
799        // After the *first* nonzero δ_x, with δ_x_last == 0, the
800        // doubling uses `delta_xs_first_inc_fact = 100` per upstream
801        // (cpp:386-389: "if delta_x_last_ == 0 ...").
802        let mut h = PdPerturbationHandler::new();
803        let d1 = h.perturb_for_wrong_inertia(0.1, None).unwrap();
804        let d2 = h.perturb_for_wrong_inertia(0.1, None).unwrap();
805        assert!((d2.delta_x - d1.delta_x * 100.0).abs() < 1e-15);
806    }
807
808    #[test]
809    fn third_perturbation_uses_inc_fact() {
810        // After delta_x_last has been set (via consider_new_system or
811        // first inc), continued growth uses `delta_xs_inc_fact = 8`.
812        let mut h = PdPerturbationHandler::new();
813        h.delta_x_curr = 1e-2;
814        h.delta_x_last = 1e-2;
815        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
816        assert!((d.delta_x - 1e-2 * 8.0).abs() < 1e-15);
817    }
818
819    #[test]
820    fn perturbation_caps_at_max_when_dcd_already_active() {
821        // When δ_c is already > 0 (e.g., perturb_always_cd, or after a
822        // singular-recovery), the fallback path inside
823        // `perturb_for_wrong_inertia` is skipped and the
824        // δ_x-overflow surfaces as `None`.
825        let mut h = PdPerturbationHandler::new();
826        h.delta_x_curr = h.delta_xs_max;
827        h.delta_c_curr = 1e-4;
828        h.delta_d_curr = 1e-4;
829        assert!(h.perturb_for_wrong_inertia(0.1, None).is_none());
830    }
831
832    #[test]
833    fn consider_new_system_with_perturb_always_cd_seeds_dcd() {
834        let mut h = PdPerturbationHandler::new();
835        h.set_perturb_always_cd(true);
836        let mu = 0.1;
837        let d = h.consider_new_system(mu, None).unwrap();
838        let expected = h.delta_cd_val * mu.powf(h.delta_cd_exp);
839        assert!((d.delta_c - expected).abs() < 1e-15);
840        assert!((d.delta_d - expected).abs() < 1e-15);
841        assert_eq!(d.delta_x, 0.0);
842        assert_eq!(d.delta_s, 0.0);
843    }
844
845    #[test]
846    fn consider_new_system_default_zeros_dcd() {
847        let mut h = PdPerturbationHandler::new();
848        let d = h.consider_new_system(0.1, None).unwrap();
849        assert_eq!(
850            d,
851            Deltas {
852                delta_x: 0.0,
853                delta_s: 0.0,
854                delta_c: 0.0,
855                delta_d: 0.0
856            }
857        );
858    }
859
860    #[test]
861    fn singular_in_test_dc_eq0_dx_eq0_seeds_dcd() {
862        let mut h = PdPerturbationHandler::new();
863        let _ = h.consider_new_system(0.1, None).unwrap();
864        // After consider_new_system on a fresh handler, test_status
865        // should be DcEq0DxEq0 (since both flags are NotYetDetermined,
866        // and perturb_always_cd is false).
867        assert_eq!(h.test_status, TrialStatus::DcEq0DxEq0);
868        let d = h.perturb_for_singular(0.1, None).unwrap();
869        let expected = h.delta_cd_val * (0.1_f64).powf(h.delta_cd_exp);
870        assert!((d.delta_c - expected).abs() < 1e-15);
871        assert!((d.delta_d - expected).abs() < 1e-15);
872        assert_eq!(d.delta_x, 0.0);
873        assert_eq!(h.test_status, TrialStatus::DcGt0DxEq0);
874    }
875
876    #[test]
877    fn singular_when_determined_with_dc_zero_seeds_dcd() {
878        let mut h = PdPerturbationHandler::new();
879        h.hess_degenerate = DegenType::NotDegenerate;
880        h.jac_degenerate = DegenType::NotDegenerate;
881        h.test_status = TrialStatus::NoTest;
882        let d = h.perturb_for_singular(0.1, None).unwrap();
883        let expected = h.delta_cd_val * (0.1_f64).powf(h.delta_cd_exp);
884        assert!((d.delta_c - expected).abs() < 1e-15);
885    }
886
887    /// gh#540: the case upstream's `DBG_ASSERT`s actually trip on. Reach
888    /// `perturb_for_singular` a *second* time, from a rung of the δ_x ladder,
889    /// while the Jacobian flag is still undetermined — so `finalize_test` has
890    /// not yet resolved it and the `DcGt0DxEq0` arm is entered with
891    /// `δ_x > 0`, against its `debug_assert!(delta_x_curr == 0.0 && ...)`.
892    /// Every real linear solver can produce this sequence (MUMPS
893    /// `INFO(1) = -10`, MA27 `IFLAG = 3`, and pounce's inertia-trust floor
894    /// all report singularity from anywhere on the ladder), so the
895    /// precondition is an assumption rather than an invariant, and the
896    /// handler has to survive it rather than assert it.
897    #[test]
898    fn a_second_singular_verdict_from_the_ladder_does_not_trip_the_probe() {
899        let mut h = PdPerturbationHandler::new();
900        let _ = h.consider_new_system(0.1, None).unwrap();
901        // Singular at δ_x = 0 → the probe raises δ_c and moves to DcGt0DxEq0,
902        // leaving the Jacobian flag undetermined.
903        let _ = h.perturb_for_singular(0.1, None).unwrap();
904        assert_eq!(h.test_status, TrialStatus::DcGt0DxEq0);
905        assert_eq!(h.jac_degenerate, DegenType::NotYetDetermined);
906        // WrongInertia next → δ_x leaves zero. `finalize_test` resolves the
907        // Hessian flag but leaves the Jacobian one undetermined (it needs
908        // `degen_iters_max` trials), so the probe is still nominally running.
909        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
910        assert!(d.delta_x > 0.0);
911        assert_eq!(h.jac_degenerate, DegenType::NotYetDetermined);
912        assert_eq!(h.test_status, TrialStatus::DcGt0DxEq0);
913        // ...and now Singular again, with δ_x > 0 under a `DxEq0` status.
914        // Pre-#540 this is the `debug_assert` that fires.
915        let d = h
916            .perturb_for_singular(0.1, None)
917            .expect("a singular verdict from the ladder must not be fatal");
918        assert!(
919            d.delta_c > 0.0,
920            "δ_c was dropped by the abandoned probe: {}",
921            d.delta_c,
922        );
923        assert_eq!(
924            h.test_status,
925            TrialStatus::NoTest,
926            "a probe whose precondition no longer holds must be abandoned",
927        );
928    }
929
930    /// The same guard on the commoner sequence, where `finalize_test` has
931    /// already resolved both flags by the time the `Singular` verdict lands:
932    /// δ_c comes up and the δ_x rung the ladder already paid for is kept.
933    #[test]
934    fn singular_after_a_delta_x_step_abandons_the_probe() {
935        let mut h = PdPerturbationHandler::new();
936        // Fresh system: both flags undetermined, so the probe arms as
937        // DcEq0DxEq0 with δ_x = δ_c = 0.
938        let _ = h.consider_new_system(0.1, None).unwrap();
939        assert_eq!(h.test_status, TrialStatus::DcEq0DxEq0);
940        // First factorization came back WrongInertia — δ_x leaves zero while
941        // `test_status` still says the probe is running at δ_x = 0.
942        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
943        assert!(d.delta_x > 0.0);
944        // Second factorization comes back Singular.
945        let d = h.perturb_for_singular(0.1, None).unwrap();
946        let expected_cd = h.delta_cd_val * (0.1_f64).powf(h.delta_cd_exp);
947        assert!(
948            (d.delta_c - expected_cd).abs() < 1e-20,
949            "δ_c was not raised: {}",
950            d.delta_c,
951        );
952        assert_eq!(d.delta_c, d.delta_d);
953        assert_eq!(
954            d.delta_x, h.delta_xs_init,
955            "the δ_x ladder lost the rung it had already paid for",
956        );
957        assert_eq!(
958            h.test_status,
959            TrialStatus::NoTest,
960            "a probe whose precondition no longer holds must be abandoned, \
961             not carried into finalize_test",
962        );
963    }
964
965    #[test]
966    fn finalize_test_sets_not_degenerate_after_dc_eq0_dx_eq0_pass() {
967        let mut h = PdPerturbationHandler::new();
968        let _ = h.consider_new_system(0.1, None).unwrap();
969        // Simulate the next call recognizing that the previous trial
970        // factor was non-singular: a fresh consider_new_system runs
971        // finalize_test first.
972        let _ = h.consider_new_system(0.1, None).unwrap();
973        assert_eq!(h.hess_degenerate, DegenType::NotDegenerate);
974        assert_eq!(h.jac_degenerate, DegenType::NotDegenerate);
975    }
976
977    #[test]
978    fn current_perturbation_returns_committed_values() {
979        let mut h = PdPerturbationHandler::new();
980        h.delta_x_curr = 1.0;
981        h.delta_s_curr = 2.0;
982        h.delta_c_curr = 3.0;
983        h.delta_d_curr = 4.0;
984        let d = h.current_perturbation();
985        assert_eq!(d.delta_x, 1.0);
986        assert_eq!(d.delta_s, 2.0);
987        assert_eq!(d.delta_c, 3.0);
988        assert_eq!(d.delta_d, 4.0);
989    }
990}