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}
105
106impl Default for PdPerturbationHandler {
107    fn default() -> Self {
108        Self {
109            delta_xs_max: 1e20,
110            delta_xs_min: 1e-20,
111            delta_xs_first_inc_fact: 100.0,
112            delta_xs_inc_fact: 8.0,
113            delta_xs_dec_fact: 1.0 / 3.0,
114            delta_xs_init: 1e-4,
115            delta_cd_val: 1e-8,
116            delta_cd_exp: 0.25,
117            perturb_always_cd: false,
118            reset_last: false,
119            degen_iters_max: 3,
120            delta_x_curr: 0.0,
121            delta_s_curr: 0.0,
122            delta_c_curr: 0.0,
123            delta_d_curr: 0.0,
124            delta_x_last: 0.0,
125            delta_s_last: 0.0,
126            delta_c_last: 0.0,
127            delta_d_last: 0.0,
128            get_deltas_for_wrong_inertia_called: false,
129            hess_degenerate: DegenType::NotYetDetermined,
130            jac_degenerate: DegenType::NotYetDetermined,
131            degen_iters: 0,
132            test_status: TrialStatus::NoTest,
133        }
134    }
135}
136
137/// Snapshot of the four perturbations after a state-machine call.
138#[derive(Debug, Clone, Copy, PartialEq)]
139pub struct Deltas {
140    pub delta_x: Number,
141    pub delta_s: Number,
142    pub delta_c: Number,
143    pub delta_d: Number,
144}
145
146impl PdPerturbationHandler {
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    /// Configure `perturb_always_cd_` and rebuild the initial `jac`
152    /// state. Mirrors upstream's `InitializeImpl`.
153    pub fn set_perturb_always_cd(&mut self, on: bool) {
154        self.perturb_always_cd = on;
155        self.jac_degenerate = if on {
156            DegenType::NotDegenerate
157        } else {
158            DegenType::NotYetDetermined
159        };
160    }
161
162    /// First call when starting a new aug-system. `mu` is the current
163    /// barrier parameter (used by the `δ_cd` formula).
164    /// Returns `None` if no suitable starting perturbation could be
165    /// found (the caller bails).
166    pub fn consider_new_system(
167        &mut self,
168        mu: Number,
169        ip_data: Option<&dyn PerturbationSink>,
170    ) -> Option<Deltas> {
171        self.finalize_test(ip_data);
172
173        // Bookkeeping: roll the previous trial's `_curr` values into
174        // `_last` (matches upstream cpp:158-183).
175        if self.reset_last {
176            self.delta_x_last = self.delta_x_curr;
177            self.delta_s_last = self.delta_s_curr;
178            self.delta_c_last = self.delta_c_curr;
179            self.delta_d_last = self.delta_d_curr;
180        } else {
181            if self.delta_x_curr > 0.0 {
182                self.delta_x_last = self.delta_x_curr;
183            }
184            if self.delta_s_curr > 0.0 {
185                self.delta_s_last = self.delta_s_curr;
186            }
187            if self.delta_c_curr > 0.0 {
188                self.delta_c_last = self.delta_c_curr;
189            }
190            if self.delta_d_curr > 0.0 {
191                self.delta_d_last = self.delta_d_curr;
192            }
193        }
194
195        let undet = matches!(self.hess_degenerate, DegenType::NotYetDetermined)
196            || matches!(self.jac_degenerate, DegenType::NotYetDetermined);
197        self.test_status = if undet {
198            if self.perturb_always_cd {
199                TrialStatus::DcGt0DxEq0
200            } else {
201                TrialStatus::DcEq0DxEq0
202            }
203        } else {
204            TrialStatus::NoTest
205        };
206
207        let mut delta_c = if matches!(self.jac_degenerate, DegenType::Degenerate) {
208            let v = self.delta_cd(mu);
209            self.delta_c_curr = v;
210            append_info(ip_data, "l");
211            v
212        } else if self.perturb_always_cd {
213            let v = self.delta_cd(mu);
214            self.delta_c_curr = v;
215            v
216        } else {
217            self.delta_c_curr = 0.0;
218            0.0
219        };
220        let mut delta_d = delta_c;
221        self.delta_d_curr = delta_d;
222
223        let mut delta_x = 0.0;
224        let mut delta_s = 0.0;
225
226        if matches!(self.hess_degenerate, DegenType::Degenerate) {
227            self.delta_x_curr = 0.0;
228            self.delta_s_curr = 0.0;
229            if !self.get_deltas_for_wrong_inertia(
230                &mut delta_x,
231                &mut delta_s,
232                &mut delta_c,
233                &mut delta_d,
234                ip_data,
235            ) {
236                return None;
237            }
238        }
239
240        self.delta_x_curr = delta_x;
241        self.delta_s_curr = delta_s;
242        self.delta_c_curr = delta_c;
243        self.delta_d_curr = delta_d;
244        set_info_regu_x(ip_data, delta_x);
245        self.get_deltas_for_wrong_inertia_called = false;
246
247        Some(Deltas {
248            delta_x,
249            delta_s,
250            delta_c,
251            delta_d,
252        })
253    }
254
255    /// Escalation after `Singular` factorization status. Mirrors
256    /// `PerturbForSingularity` (cpp:245-364).
257    pub fn perturb_for_singular(
258        &mut self,
259        mu: Number,
260        ip_data: Option<&dyn PerturbationSink>,
261    ) -> Option<Deltas> {
262        let mut delta_x = 0.0;
263        let mut delta_s = 0.0;
264        let mut delta_c = 0.0;
265        let mut delta_d = 0.0;
266
267        // Upstream's `TrialStatus` arms below assert that the degeneracy
268        // probe is still in the state that named it — `δ_x == 0` for the
269        // `DxEq0` statuses, and so on. Those are `DBG_ASSERT`s upstream,
270        // i.e. assumptions rather than invariants: every real linear
271        // solver can report `Singular` from *any* rung of the δ_x ladder
272        // (MUMPS `INFO(1) = -10`, MA27 `IFLAG = 3`, and — since pounce
273        // gh#540 — feral's inertia-trust floor), at which point the probe
274        // has already been disturbed and its arm no longer applies.
275        // Abandon the probe and fall through to the determined-state path,
276        // which asserts nothing and does the right thing from wherever the
277        // perturbations happen to be: raise δ_c if it is still zero,
278        // otherwise take a δ_x step.
279        let probe_intact = match self.test_status {
280            TrialStatus::DcEq0DxEq0 => self.delta_x_curr == 0.0 && self.delta_c_curr == 0.0,
281            TrialStatus::DcGt0DxEq0 => self.delta_x_curr == 0.0 && self.delta_c_curr > 0.0,
282            TrialStatus::DcEq0DxGt0 => self.delta_x_curr > 0.0 && self.delta_c_curr == 0.0,
283            TrialStatus::DcGt0DxGt0 | TrialStatus::NoTest => true,
284        };
285        if !probe_intact {
286            self.test_status = TrialStatus::NoTest;
287        }
288
289        let undet = probe_intact
290            && (matches!(self.hess_degenerate, DegenType::NotYetDetermined)
291                || matches!(self.jac_degenerate, DegenType::NotYetDetermined));
292        if undet {
293            match self.test_status {
294                TrialStatus::DcEq0DxEq0 => {
295                    debug_assert!(self.delta_x_curr == 0.0 && self.delta_c_curr == 0.0);
296                    if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
297                        let v = self.delta_cd(mu);
298                        self.delta_c_curr = v;
299                        self.delta_d_curr = v;
300                        self.test_status = TrialStatus::DcGt0DxEq0;
301                    } else {
302                        debug_assert!(matches!(self.hess_degenerate, DegenType::NotYetDetermined));
303                        if !self.get_deltas_for_wrong_inertia(
304                            &mut delta_x,
305                            &mut delta_s,
306                            &mut delta_c,
307                            &mut delta_d,
308                            ip_data,
309                        ) {
310                            return None;
311                        }
312                        self.test_status = TrialStatus::DcEq0DxGt0;
313                    }
314                }
315                TrialStatus::DcGt0DxEq0 => {
316                    debug_assert!(self.delta_x_curr == 0.0 && self.delta_c_curr > 0.0);
317                    debug_assert!(matches!(self.jac_degenerate, DegenType::NotYetDetermined));
318                    if !self.perturb_always_cd {
319                        self.delta_c_curr = 0.0;
320                        self.delta_d_curr = 0.0;
321                        if !self.get_deltas_for_wrong_inertia(
322                            &mut delta_x,
323                            &mut delta_s,
324                            &mut delta_c,
325                            &mut delta_d,
326                            ip_data,
327                        ) {
328                            return None;
329                        }
330                        self.test_status = TrialStatus::DcEq0DxGt0;
331                    } else if !self.get_deltas_for_wrong_inertia(
332                        &mut delta_x,
333                        &mut delta_s,
334                        &mut delta_c,
335                        &mut delta_d,
336                        ip_data,
337                    ) {
338                        return None;
339                    } else {
340                        self.test_status = TrialStatus::DcGt0DxGt0;
341                    }
342                }
343                TrialStatus::DcEq0DxGt0 => {
344                    debug_assert!(self.delta_x_curr > 0.0 && self.delta_c_curr == 0.0);
345                    let v = self.delta_cd(mu);
346                    self.delta_c_curr = v;
347                    self.delta_d_curr = v;
348                    if !self.get_deltas_for_wrong_inertia(
349                        &mut delta_x,
350                        &mut delta_s,
351                        &mut delta_c,
352                        &mut delta_d,
353                        ip_data,
354                    ) {
355                        return None;
356                    }
357                    self.test_status = TrialStatus::DcGt0DxGt0;
358                }
359                TrialStatus::DcGt0DxGt0 => {
360                    if !self.get_deltas_for_wrong_inertia(
361                        &mut delta_x,
362                        &mut delta_s,
363                        &mut delta_c,
364                        &mut delta_d,
365                        ip_data,
366                    ) {
367                        return None;
368                    }
369                }
370                TrialStatus::NoTest => {
371                    debug_assert!(false, "perturb_for_singular: NoTest in undetermined branch");
372                }
373            }
374        } else if self.delta_c_curr > 0.0 {
375            // Already perturbed C; treat as wrong-inertia.
376            if !self.get_deltas_for_wrong_inertia(
377                &mut delta_x,
378                &mut delta_s,
379                &mut delta_c,
380                &mut delta_d,
381                ip_data,
382            ) {
383                return None;
384            }
385        } else {
386            let v = self.delta_cd(mu);
387            self.delta_c_curr = v;
388            self.delta_d_curr = v;
389            append_info(ip_data, "L");
390        }
391
392        let out = Deltas {
393            delta_x: self.delta_x_curr,
394            delta_s: self.delta_s_curr,
395            delta_c: self.delta_c_curr,
396            delta_d: self.delta_d_curr,
397        };
398        set_info_regu_x(ip_data, out.delta_x);
399        Some(out)
400    }
401
402    /// Escalation after `WrongInertia` factorization status. Mirrors
403    /// `PerturbForWrongInertia` (cpp:419-450).
404    pub fn perturb_for_wrong_inertia(
405        &mut self,
406        mu: Number,
407        ip_data: Option<&dyn PerturbationSink>,
408    ) -> Option<Deltas> {
409        if std::env::var_os("POUNCE_DBG_PERT").is_some() {
410            if let Some(d) = ip_data {
411                d.debug(&format!(
412                    "[PERT] iter={} WRONG_INERTIA mu={:.2e} dx_last={:.2e} dx_curr={:.2e}",
413                    d.iter_count(),
414                    mu,
415                    self.delta_x_last,
416                    self.delta_x_curr
417                ));
418            }
419        }
420        self.finalize_test(ip_data);
421
422        let mut delta_x = 0.0;
423        let mut delta_s = 0.0;
424        let mut delta_c = 0.0;
425        let mut delta_d = 0.0;
426        let mut ok = self.get_deltas_for_wrong_inertia(
427            &mut delta_x,
428            &mut delta_s,
429            &mut delta_c,
430            &mut delta_d,
431            ip_data,
432        );
433        // Upstream "no progress on δ_x but δ_c == 0" recovery: bring
434        // up the C/D perturbation, reset Hessian degeneracy, retry.
435        // Upstream peeks at the OUT-parameter `delta_c`, but
436        // `get_deltas_for_wrong_inertia` only writes that on success;
437        // we look at the handler's own δ_c_curr instead, which
438        // matches the algorithmic intent unambiguously.
439        if !ok && self.delta_c_curr == 0.0 {
440            debug_assert_eq!(self.delta_d_curr, 0.0);
441            let v = self.delta_cd(mu);
442            self.delta_c_curr = v;
443            self.delta_d_curr = v;
444            self.delta_x_curr = 0.0;
445            self.delta_s_curr = 0.0;
446            self.test_status = TrialStatus::NoTest;
447            if matches!(self.hess_degenerate, DegenType::Degenerate) {
448                self.hess_degenerate = DegenType::NotYetDetermined;
449            }
450            ok = self.get_deltas_for_wrong_inertia(
451                &mut delta_x,
452                &mut delta_s,
453                &mut delta_c,
454                &mut delta_d,
455                ip_data,
456            );
457        }
458        if !ok {
459            return None;
460        }
461        Some(Deltas {
462            delta_x,
463            delta_s,
464            delta_c,
465            delta_d,
466        })
467    }
468
469    /// Read the most recently committed perturbations.
470    pub fn current_perturbation(&self) -> Deltas {
471        Deltas {
472            delta_x: self.delta_x_curr,
473            delta_s: self.delta_s_curr,
474            delta_c: self.delta_c_curr,
475            delta_d: self.delta_d_curr,
476        }
477    }
478
479    /// Internal — pure escalation of `δ_x` / `δ_s`. Returns `false` if
480    /// `δ_x` would exceed `delta_xs_max`. Mirrors
481    /// `get_deltas_for_wrong_inertia`.
482    fn get_deltas_for_wrong_inertia(
483        &mut self,
484        delta_x: &mut Number,
485        delta_s: &mut Number,
486        delta_c: &mut Number,
487        delta_d: &mut Number,
488        ip_data: Option<&dyn PerturbationSink>,
489    ) -> bool {
490        if self.delta_x_curr == 0.0 {
491            self.delta_x_curr = if self.delta_x_last == 0.0 {
492                self.delta_xs_init
493            } else {
494                self.delta_xs_min
495                    .max(self.delta_x_last * self.delta_xs_dec_fact)
496            };
497        } else if self.delta_x_last == 0.0 || 1e5 * self.delta_x_last < self.delta_x_curr {
498            self.delta_x_curr *= self.delta_xs_first_inc_fact;
499        } else {
500            self.delta_x_curr *= self.delta_xs_inc_fact;
501        }
502        if self.delta_x_curr > self.delta_xs_max {
503            self.delta_x_last = 0.0;
504            self.delta_s_last = 0.0;
505            append_info(ip_data, "dx");
506            return false;
507        }
508        self.delta_s_curr = self.delta_x_curr;
509
510        *delta_x = self.delta_x_curr;
511        *delta_s = self.delta_s_curr;
512        *delta_c = self.delta_c_curr;
513        *delta_d = self.delta_d_curr;
514        set_info_regu_x(ip_data, *delta_x);
515        self.get_deltas_for_wrong_inertia_called = true;
516        true
517    }
518
519    fn delta_cd(&self, mu: Number) -> Number {
520        self.delta_cd_val * mu.powf(self.delta_cd_exp)
521    }
522
523    /// Read the test outcome from the just-completed (non-singular)
524    /// factor and update degeneracy flags. Mirrors `finalize_test`
525    /// (cpp:470-538).
526    fn finalize_test(&mut self, ip_data: Option<&dyn PerturbationSink>) {
527        match self.test_status {
528            TrialStatus::NoTest => (),
529            TrialStatus::DcEq0DxEq0 => {
530                if matches!(self.hess_degenerate, DegenType::NotYetDetermined)
531                    && matches!(self.jac_degenerate, DegenType::NotYetDetermined)
532                {
533                    self.hess_degenerate = DegenType::NotDegenerate;
534                    self.jac_degenerate = DegenType::NotDegenerate;
535                    append_info(ip_data, "Nhj ");
536                } else if matches!(self.hess_degenerate, DegenType::NotYetDetermined) {
537                    self.hess_degenerate = DegenType::NotDegenerate;
538                    append_info(ip_data, "Nh ");
539                } else if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
540                    self.jac_degenerate = DegenType::NotDegenerate;
541                    append_info(ip_data, "Nj ");
542                }
543            }
544            TrialStatus::DcGt0DxEq0 => {
545                if matches!(self.hess_degenerate, DegenType::NotYetDetermined) {
546                    self.hess_degenerate = DegenType::NotDegenerate;
547                    append_info(ip_data, "Nh ");
548                }
549                if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
550                    self.degen_iters += 1;
551                    if self.degen_iters >= self.degen_iters_max {
552                        self.jac_degenerate = DegenType::Degenerate;
553                        append_info(ip_data, "Dj ");
554                    }
555                    append_info(ip_data, "L");
556                }
557            }
558            TrialStatus::DcEq0DxGt0 => {
559                if matches!(self.jac_degenerate, DegenType::NotYetDetermined) {
560                    self.jac_degenerate = DegenType::NotDegenerate;
561                    append_info(ip_data, "Nj ");
562                }
563                if matches!(self.hess_degenerate, DegenType::NotYetDetermined) {
564                    self.degen_iters += 1;
565                    if self.degen_iters >= self.degen_iters_max {
566                        self.hess_degenerate = DegenType::Degenerate;
567                        append_info(ip_data, "Dh ");
568                    }
569                }
570            }
571            TrialStatus::DcGt0DxGt0 => {
572                self.degen_iters += 1;
573                if self.degen_iters >= self.degen_iters_max {
574                    self.hess_degenerate = DegenType::Degenerate;
575                    self.jac_degenerate = DegenType::Degenerate;
576                    append_info(ip_data, "Dhj ");
577                }
578                append_info(ip_data, "L");
579            }
580        }
581    }
582}
583
584fn append_info(sink: Option<&dyn PerturbationSink>, s: &str) {
585    if let Some(h) = sink {
586        h.append_info(s);
587    }
588}
589
590fn set_info_regu_x(sink: Option<&dyn PerturbationSink>, v: Number) {
591    if let Some(h) = sink {
592        h.set_regu_x(v);
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn first_wrong_inertia_perturbation_is_delta_xs_init() {
602        let mut h = PdPerturbationHandler::new();
603        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
604        // delta_xs_init = first_hessian_perturbation = 1e-4
605        assert!((d.delta_x - 1e-4).abs() < 1e-20);
606        assert_eq!(d.delta_x, d.delta_s);
607        assert_eq!(d.delta_c, 0.0);
608        assert_eq!(d.delta_d, 0.0);
609    }
610
611    #[test]
612    fn second_perturbation_uses_first_inc_fact() {
613        // After the *first* nonzero δ_x, with δ_x_last == 0, the
614        // doubling uses `delta_xs_first_inc_fact = 100` per upstream
615        // (cpp:386-389: "if delta_x_last_ == 0 ...").
616        let mut h = PdPerturbationHandler::new();
617        let d1 = h.perturb_for_wrong_inertia(0.1, None).unwrap();
618        let d2 = h.perturb_for_wrong_inertia(0.1, None).unwrap();
619        assert!((d2.delta_x - d1.delta_x * 100.0).abs() < 1e-15);
620    }
621
622    #[test]
623    fn third_perturbation_uses_inc_fact() {
624        // After delta_x_last has been set (via consider_new_system or
625        // first inc), continued growth uses `delta_xs_inc_fact = 8`.
626        let mut h = PdPerturbationHandler::new();
627        h.delta_x_curr = 1e-2;
628        h.delta_x_last = 1e-2;
629        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
630        assert!((d.delta_x - 1e-2 * 8.0).abs() < 1e-15);
631    }
632
633    #[test]
634    fn perturbation_caps_at_max_when_dcd_already_active() {
635        // When δ_c is already > 0 (e.g., perturb_always_cd, or after a
636        // singular-recovery), the fallback path inside
637        // `perturb_for_wrong_inertia` is skipped and the
638        // δ_x-overflow surfaces as `None`.
639        let mut h = PdPerturbationHandler::new();
640        h.delta_x_curr = h.delta_xs_max;
641        h.delta_c_curr = 1e-4;
642        h.delta_d_curr = 1e-4;
643        assert!(h.perturb_for_wrong_inertia(0.1, None).is_none());
644    }
645
646    #[test]
647    fn consider_new_system_with_perturb_always_cd_seeds_dcd() {
648        let mut h = PdPerturbationHandler::new();
649        h.set_perturb_always_cd(true);
650        let mu = 0.1;
651        let d = h.consider_new_system(mu, None).unwrap();
652        let expected = h.delta_cd_val * mu.powf(h.delta_cd_exp);
653        assert!((d.delta_c - expected).abs() < 1e-15);
654        assert!((d.delta_d - expected).abs() < 1e-15);
655        assert_eq!(d.delta_x, 0.0);
656        assert_eq!(d.delta_s, 0.0);
657    }
658
659    #[test]
660    fn consider_new_system_default_zeros_dcd() {
661        let mut h = PdPerturbationHandler::new();
662        let d = h.consider_new_system(0.1, None).unwrap();
663        assert_eq!(
664            d,
665            Deltas {
666                delta_x: 0.0,
667                delta_s: 0.0,
668                delta_c: 0.0,
669                delta_d: 0.0
670            }
671        );
672    }
673
674    #[test]
675    fn singular_in_test_dc_eq0_dx_eq0_seeds_dcd() {
676        let mut h = PdPerturbationHandler::new();
677        let _ = h.consider_new_system(0.1, None).unwrap();
678        // After consider_new_system on a fresh handler, test_status
679        // should be DcEq0DxEq0 (since both flags are NotYetDetermined,
680        // and perturb_always_cd is false).
681        assert_eq!(h.test_status, TrialStatus::DcEq0DxEq0);
682        let d = h.perturb_for_singular(0.1, None).unwrap();
683        let expected = h.delta_cd_val * (0.1_f64).powf(h.delta_cd_exp);
684        assert!((d.delta_c - expected).abs() < 1e-15);
685        assert!((d.delta_d - expected).abs() < 1e-15);
686        assert_eq!(d.delta_x, 0.0);
687        assert_eq!(h.test_status, TrialStatus::DcGt0DxEq0);
688    }
689
690    #[test]
691    fn singular_when_determined_with_dc_zero_seeds_dcd() {
692        let mut h = PdPerturbationHandler::new();
693        h.hess_degenerate = DegenType::NotDegenerate;
694        h.jac_degenerate = DegenType::NotDegenerate;
695        h.test_status = TrialStatus::NoTest;
696        let d = h.perturb_for_singular(0.1, None).unwrap();
697        let expected = h.delta_cd_val * (0.1_f64).powf(h.delta_cd_exp);
698        assert!((d.delta_c - expected).abs() < 1e-15);
699    }
700
701    /// gh#540: the case upstream's `DBG_ASSERT`s actually trip on. Reach
702    /// `perturb_for_singular` a *second* time, from a rung of the δ_x ladder,
703    /// while the Jacobian flag is still undetermined — so `finalize_test` has
704    /// not yet resolved it and the `DcGt0DxEq0` arm is entered with
705    /// `δ_x > 0`, against its `debug_assert!(delta_x_curr == 0.0 && ...)`.
706    /// Every real linear solver can produce this sequence (MUMPS
707    /// `INFO(1) = -10`, MA27 `IFLAG = 3`, and pounce's inertia-trust floor
708    /// all report singularity from anywhere on the ladder), so the
709    /// precondition is an assumption rather than an invariant, and the
710    /// handler has to survive it rather than assert it.
711    #[test]
712    fn a_second_singular_verdict_from_the_ladder_does_not_trip_the_probe() {
713        let mut h = PdPerturbationHandler::new();
714        let _ = h.consider_new_system(0.1, None).unwrap();
715        // Singular at δ_x = 0 → the probe raises δ_c and moves to DcGt0DxEq0,
716        // leaving the Jacobian flag undetermined.
717        let _ = h.perturb_for_singular(0.1, None).unwrap();
718        assert_eq!(h.test_status, TrialStatus::DcGt0DxEq0);
719        assert_eq!(h.jac_degenerate, DegenType::NotYetDetermined);
720        // WrongInertia next → δ_x leaves zero. `finalize_test` resolves the
721        // Hessian flag but leaves the Jacobian one undetermined (it needs
722        // `degen_iters_max` trials), so the probe is still nominally running.
723        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
724        assert!(d.delta_x > 0.0);
725        assert_eq!(h.jac_degenerate, DegenType::NotYetDetermined);
726        assert_eq!(h.test_status, TrialStatus::DcGt0DxEq0);
727        // ...and now Singular again, with δ_x > 0 under a `DxEq0` status.
728        // Pre-#540 this is the `debug_assert` that fires.
729        let d = h
730            .perturb_for_singular(0.1, None)
731            .expect("a singular verdict from the ladder must not be fatal");
732        assert!(
733            d.delta_c > 0.0,
734            "δ_c was dropped by the abandoned probe: {}",
735            d.delta_c,
736        );
737        assert_eq!(
738            h.test_status,
739            TrialStatus::NoTest,
740            "a probe whose precondition no longer holds must be abandoned",
741        );
742    }
743
744    /// The same guard on the commoner sequence, where `finalize_test` has
745    /// already resolved both flags by the time the `Singular` verdict lands:
746    /// δ_c comes up and the δ_x rung the ladder already paid for is kept.
747    #[test]
748    fn singular_after_a_delta_x_step_abandons_the_probe() {
749        let mut h = PdPerturbationHandler::new();
750        // Fresh system: both flags undetermined, so the probe arms as
751        // DcEq0DxEq0 with δ_x = δ_c = 0.
752        let _ = h.consider_new_system(0.1, None).unwrap();
753        assert_eq!(h.test_status, TrialStatus::DcEq0DxEq0);
754        // First factorization came back WrongInertia — δ_x leaves zero while
755        // `test_status` still says the probe is running at δ_x = 0.
756        let d = h.perturb_for_wrong_inertia(0.1, None).unwrap();
757        assert!(d.delta_x > 0.0);
758        // Second factorization comes back Singular.
759        let d = h.perturb_for_singular(0.1, None).unwrap();
760        let expected_cd = h.delta_cd_val * (0.1_f64).powf(h.delta_cd_exp);
761        assert!(
762            (d.delta_c - expected_cd).abs() < 1e-20,
763            "δ_c was not raised: {}",
764            d.delta_c,
765        );
766        assert_eq!(d.delta_c, d.delta_d);
767        assert_eq!(
768            d.delta_x, h.delta_xs_init,
769            "the δ_x ladder lost the rung it had already paid for",
770        );
771        assert_eq!(
772            h.test_status,
773            TrialStatus::NoTest,
774            "a probe whose precondition no longer holds must be abandoned, \
775             not carried into finalize_test",
776        );
777    }
778
779    #[test]
780    fn finalize_test_sets_not_degenerate_after_dc_eq0_dx_eq0_pass() {
781        let mut h = PdPerturbationHandler::new();
782        let _ = h.consider_new_system(0.1, None).unwrap();
783        // Simulate the next call recognizing that the previous trial
784        // factor was non-singular: a fresh consider_new_system runs
785        // finalize_test first.
786        let _ = h.consider_new_system(0.1, None).unwrap();
787        assert_eq!(h.hess_degenerate, DegenType::NotDegenerate);
788        assert_eq!(h.jac_degenerate, DegenType::NotDegenerate);
789    }
790
791    #[test]
792    fn current_perturbation_returns_committed_values() {
793        let mut h = PdPerturbationHandler::new();
794        h.delta_x_curr = 1.0;
795        h.delta_s_curr = 2.0;
796        h.delta_c_curr = 3.0;
797        h.delta_d_curr = 4.0;
798        let d = h.current_perturbation();
799        assert_eq!(d.delta_x, 1.0);
800        assert_eq!(d.delta_s, 2.0);
801        assert_eq!(d.delta_c, 3.0);
802        assert_eq!(d.delta_d, 4.0);
803    }
804}