Skip to main content

gam_problem/
custom_family_error.rs

1//! Custom-family error type and its String conversions.
2
3use thiserror::Error;
4
5use crate::{IdentifiabilityAudit, MapUniquenessError};
6
7
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum JointNewtonTerminalReason {
10    CycleBudget,
11    FullyRejectedExactFixedPoint {
12        consecutive_cycles: usize,
13        joint_trust_radius: f64,
14        rejection_counts: [usize; 4],
15    },
16    FullyRejectedAtTrustRegionFloor {
17        consecutive_cycles: usize,
18        joint_trust_radius: f64,
19        rejection_counts: [usize; 4],
20    },
21}
22
23impl std::fmt::Display for JointNewtonTerminalReason {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::CycleBudget => write!(f, "cycle budget"),
27            Self::FullyRejectedExactFixedPoint {
28                consecutive_cycles,
29                joint_trust_radius,
30                rejection_counts,
31            } => write!(
32                f,
33                "complete rejected-cycle state repeated {consecutive_cycles} times at \
34                 trust radius {joint_trust_radius:.6e}; rejects \
35                 [model,likelihood,objective,feasibility]={rejection_counts:?}"
36            ),
37            Self::FullyRejectedAtTrustRegionFloor {
38                consecutive_cycles,
39                joint_trust_radius,
40                rejection_counts,
41            } => write!(
42                f,
43                "all attempts rejected for {consecutive_cycles} cycles at the absolute \
44                 trust-region floor {joint_trust_radius:.6e}; rejects \
45                 [model,likelihood,objective,feasibility]={rejection_counts:?}"
46            ),
47        }
48    }
49}
50
51/// The blockwise inner loop's terminal decision variables — the quantities its
52/// convergence verdict is actually taken on.
53///
54/// The loop certifies with
55/// `max_accepted_step <= step_tol && objective_change <= objective_tol`, and then
56/// `joint_stationarity_ok || max_proposed_step <= step_tol`. Reporting only the
57/// cycle count cannot say which of those four conjuncts failed, and they have
58/// different causes: steps still large means the solve needs more cycles, steps
59/// tiny with `joint_stationarity_ok == false` means the exact joint gate is the
60/// blocker rather than the budget, and an `objective_change` above tolerance
61/// means the iterate is still moving. This is deliberately NOT a KKT residual:
62/// `BlockwiseInnerResult::kkt_residual` is `None` off a converged iterate on
63/// purpose, because no caller may trust an IFT correction there, so the honest
64/// diagnostic is the decision variables themselves rather than a residual
65/// recomputed at a non-KKT point.
66/// The stationarity residual denominated the way its own gate denominates it.
67///
68/// The inner joint-Newton gate is `R ≤ inner_tol · (1 + scale)` with
69/// `scale = max(‖∇L‖∞, ‖Sβ‖∞, ‖∇Φ‖∞)`, so dividing through by `(1 + scale)`
70/// gives the single scalar the gate actually tests against one fixed number:
71///
72/// ```text
73/// relative_stationarity(R, scale) = R / (1 + scale) ≤ inner_tol   ⟺   gate accepts
74/// ```
75///
76/// Two properties make this — and not `R`, and not `R/residual_tol` — the
77/// column to rank a population of refusals on (gam#2713):
78///
79/// * It is comparable across solves. `R` alone is not: a single suite spans a
80///   `1.18e10` range of `scale`, so an absolute `R = 5.3` at `scale = 5.3e6` is
81///   stationary to one part in a million while `R = 5.3` at `scale = 1` is not
82///   stationary at all. `R/residual_tol` is not either: it divides by
83///   `inner_tol` as well, and `inner_tol` takes two different values in this
84///   code (the `1e-6` default and the `1e-11` derivative-lane floor), so rows
85///   from the two lanes are on axes that differ by five orders of magnitude.
86/// * It handles the scale-free end explicitly rather than by accident. The
87///   `1 +` is not cosmetic: at `scale → 0` there is no relative scale to speak
88///   of and the criterion must degrade to the ABSOLUTE `R ≤ inner_tol`, which
89///   is exactly what this expression does. A bare `R/scale` would instead
90///   divide by zero and rank a perfectly-converged small-scale solve at
91///   infinity. For `scale ≫ 1` the two agree to within `1/scale`.
92///
93/// Deliberately NOT applied to `best_stationarity_residual`: that value was
94/// computed at a different iterate, whose `scale` this state does not carry.
95/// Rescaling it by the terminal `scale` would produce a number that is neither
96/// the best relative stationarity nor anything else.
97#[must_use]
98pub fn relative_stationarity(stationarity_residual: f64, stationarity_scale: f64) -> f64 {
99    stationarity_residual / (1.0 + stationarity_scale)
100}
101
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum InnerConvergenceTerminalState {
104    /// The blockwise Gauss-Seidel route's terminal cycle.
105    Blockwise {
106        cycle: usize,
107        max_accepted_step: f64,
108        max_proposed_step: f64,
109        step_tol: f64,
110        objective_change: f64,
111        objective_tol: f64,
112        joint_stationarity_ok: bool,
113    },
114    /// The exact joint-Newton route's terminal cycle. This route DOES have a
115    /// genuine stationarity residual (the blockwise one does not, off a
116    /// converged iterate), and it has a third outcome the other lacks:
117    /// `resolvable_negative_curvature` marks a first-order stationary STRICT
118    /// SADDLE, where the score and the Newton proposal both vanish but the exact
119    /// penalized Hessian has resolvable negative curvature. That refuses
120    /// convergence deliberately, and it is nothing like exhausting a budget.
121    JointNewton {
122        cycle: usize,
123        stationarity_residual: f64,
124        residual_tol: f64,
125        /// The magnitude the stationarity residual is denominated against:
126        /// `max(‖∇L‖∞, ‖Sβ‖∞, ‖∇Φ‖∞)` at the terminal iterate, i.e. the `scale`
127        /// in `residual_tol = inner_tol · (1 + scale)`.
128        ///
129        /// Carried because WITHOUT it the message cannot be ranked (gam#2713).
130        /// The natural thing to do with a printed `residual (tol=…)` pair is to
131        /// form `R/T` and read it as "N× over tolerance"; that ratio is
132        /// `≈ (R/scale)/inner_tol`, so it mixes two different tolerances (the
133        /// `1e-6` default and the derivative lane's `1e-11`
134        /// `JOINT_LAML_DERIV_INNER_TOL_FLOOR`) and it is ANTI-correlated with
135        /// convergence across part of the range. Measured over 41 refusal pairs
136        /// from one survival sweep: a row printing `R/T = 238×` was stationary
137        /// to `R/scale = 2.4e-9` — converged to nine digits — while a row
138        /// printing `R/T = 1.4e3×` sat at `R/scale = 1.4e-3`, a million times
139        /// less converged. Ranking on `R/T` sends triage to the first row.
140        ///
141        /// The comparable column is [`relative_stationarity`], printed below,
142        /// which is the gate's own quantity: the gate accepts exactly when it
143        /// is `≤ inner_tol`, so it is `0` at the optimum, `~1` where the
144        /// residual has collapsed onto one of its own terms, and directly
145        /// comparable across both `inner_tol` regimes.
146        stationarity_scale: f64,
147        step_inf: f64,
148        step_tol: f64,
149        resolvable_negative_curvature: bool,
150        /// The smallest stationarity residual this solve actually computed, and
151        /// how many cycles have passed since it last improved.
152        ///
153        /// The terminal residual alone cannot separate a solve that never got
154        /// close from one that reached a near-tolerance point and then walked
155        /// away from it, and those are different defects with different fixes.
156        /// Measured on the transformation-normal wine arm (#2600): the terminal
157        /// residual is `1.906e0` while the smallest this same solve computed is
158        /// `1.578e-3` — 1200x better, within 1.9x of `residual_tol`, and reached
159        /// 27 cycles earlier, after which every accepted step raised the
160        /// residual again. Read from the terminal value alone that solve looks
161        /// like it never approached stationarity; read with the best value it
162        /// is a solve that drifted off a point it had essentially reached.
163        best_stationarity_residual: f64,
164        cycles_since_best_residual: usize,
165        termination_reason: JointNewtonTerminalReason,
166    },
167}
168
169impl std::fmt::Display for InnerConvergenceTerminalState {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        match self {
172            Self::Blockwise {
173                cycle,
174                max_accepted_step,
175                max_proposed_step,
176                step_tol,
177                objective_change,
178                objective_tol,
179                joint_stationarity_ok,
180            } => write!(
181                f,
182                "blockwise terminal cycle {cycle}: max_accepted_step={max_accepted_step:.6e} \
183                 (tol={step_tol:.6e}), max_proposed_step={max_proposed_step:.6e}, \
184                 objective_change={objective_change:.6e} (tol={objective_tol:.6e}), \
185                 joint_stationarity_ok={joint_stationarity_ok}"
186            ),
187            Self::JointNewton {
188                cycle,
189                stationarity_residual,
190                residual_tol,
191                stationarity_scale,
192                step_inf,
193                step_tol,
194                resolvable_negative_curvature,
195                best_stationarity_residual,
196                cycles_since_best_residual,
197                termination_reason,
198            } => write!(
199                f,
200                "joint-Newton terminal cycle {cycle}: \
201                 stationarity_residual={stationarity_residual:.6e} (tol={residual_tol:.6e}), \
202                 relative_stationarity={:.6e} \
203                 (= residual/(1+scale), scale={stationarity_scale:.6e}; \
204                 THIS is the comparable column, not residual/tol), \
205                 step_inf={step_inf:.6e} (tol={step_tol:.6e}), \
206                 resolvable_negative_curvature={resolvable_negative_curvature}, \
207                 best_stationarity_residual={best_stationarity_residual:.6e} \
208                 (last improved {cycles_since_best_residual} cycle(s) before this one), \
209                 termination={termination_reason}",
210                relative_stationarity(*stationarity_residual, *stationarity_scale),
211            ),
212        }
213    }
214}
215
216/// Render the projected-KKT comparison in the inner-refusal message.
217///
218/// The pair used to be printed as `|r|_inf={:?} against tol={:?}`, which on the
219/// common path renders `|r|_inf=None against tol=None` — **two absences laid out
220/// as a comparison**. That reads as a measurement that was taken and came out
221/// unfavourable, and it is the opposite: nothing was measured. It cost real time
222/// on gam#2600, where the phrase sat in every refusal while the actual decision
223/// variables (which the `[{terminal}]` block does carry) said something quite
224/// different. A missing value has to say it is missing, and which side is
225/// missing, because "the solver emitted no KKT diagnostic on this path" and "the
226/// residual is 4e5x its tolerance" call for different next steps.
227fn render_projected_kkt_comparison(residual: Option<f64>, tol: Option<f64>) -> String {
228    match (residual, tol) {
229        (Some(residual), Some(tol)) => format!(
230            "projected KKT residual |r|_inf={residual:.6e} against tol={tol:.6e}"
231        ),
232        (Some(residual), None) => format!(
233            "projected KKT residual |r|_inf={residual:.6e}; \
234             no stationarity tolerance was recorded to compare it against"
235        ),
236        (None, Some(tol)) => format!(
237            "no projected KKT residual was recorded; the stationarity tolerance \
238             on this path was {tol:.6e}"
239        ),
240        (None, None) => "this solver path emits no typed projected-KKT diagnostic, so \
241                         neither a residual nor a tolerance was recorded — read the \
242                         terminal decision variables above instead"
243            .to_string(),
244    }
245}
246
247#[derive(Debug, Clone, Error)]
248pub enum CustomFamilyError {
249    #[error("custom-family invalid input in {context}: {reason}")]
250    InvalidInput {
251        context: &'static str,
252        reason: String,
253    },
254    #[error("custom-family optimization error in {context}: {reason}")]
255    Optimization {
256        context: &'static str,
257        reason: String,
258    },
259    #[error("{reason}")]
260    DimensionMismatch { reason: String },
261    #[error("{reason}")]
262    NumericalFailure { reason: String },
263    #[error("{reason}")]
264    ConstraintViolation { reason: String },
265    #[error("{reason}")]
266    UnsupportedConfiguration { reason: String },
267    /// The inner solve did not reach its KKT condition at THIS trial
268    /// point, so the analytic outer gradient/Hessian cannot be exposed
269    /// (they require `F_beta(beta, theta) = 0`).
270    ///
271    /// This is a statement about one `theta`, not about the problem: the
272    /// outer search should treat the trial as infeasible, back off, and
273    /// continue. It previously travelled as
274    /// [`UnsupportedConfiguration`](Self::UnsupportedConfiguration) — a
275    /// variant that *means* the configuration is structurally
276    /// unsupported, i.e. fatal — with the real distinction encoded only
277    /// in the message text. Downstream then had to recover it by
278    /// substring-matching that text, and two call sites reached opposite
279    /// verdicts on the same error (#2553). Choosing the variant that says
280    /// what happened removes the need to guess.
281    #[error(
282        "custom-family inner solve did not converge after {cycles} cycle(s) [{}] \
283         ({}); \
284         refusing to expose profile objective derivatives for theta_dim={theta_dim} \
285         (rho_dim={rho_dim}, psi_dim={psi_dim}). The analytic outer gradient/Hessian \
286         require the inner KKT equation F_beta(beta, theta)=0; returning a value with \
287         zero or shape-only derivatives is mathematically inconsistent. This trial \
288         point is infeasible; the outer search may step away from it.",
289        match terminal {
290            Some(state) => state.to_string(),
291            None => "no terminal convergence state was recorded".to_string(),
292        },
293        render_projected_kkt_comparison(*kkt_residual, *kkt_tol)
294    )]
295    InnerSolveNotConverged {
296        cycles: usize,
297        /// The decision variables the inner loop's verdict was taken on. See
298        /// [`InnerConvergenceTerminalState`] — a cycle count alone cannot say
299        /// which conjunct of the convergence test failed.
300        terminal: Option<InnerConvergenceTerminalState>,
301        /// Sup-norm of the projected KKT residual at the terminal inner iterate,
302        /// i.e. the quantity this refusal was decided against. A cycle count
303        /// alone cannot distinguish a solve that ran out of budget one order
304        /// from its tolerance — where the budget is the thing to look at — from
305        /// one sitting many orders away, which is a stalled or diverging solve
306        /// and a different defect entirely. `None` when the producing solver
307        /// path emits no typed KKT diagnostic (blockwise NR fallback,
308        /// eager-stop), which is itself worth seeing in the refusal.
309        kkt_residual: Option<f64>,
310        /// The stationarity tolerance `kkt_residual` was compared against.
311        kkt_tol: Option<f64>,
312        theta_dim: usize,
313        rho_dim: usize,
314        psi_dim: usize,
315    },
316    #[error("{reason}")]
317    BasisDecompositionFailed { reason: String },
318    /// Pre-fit cross-block identifiability audit refused the fit. The
319    /// joint design across `ParameterBlockSpec`s carries a rank
320    /// deficiency that the post-`joint_null_rotation` absorption did
321    /// not resolve: two or more blocks contribute the same direction,
322    /// or a structural >2-way alias was detected without per-pair
323    /// attribution. The full `IdentifiabilityAudit` is held so
324    /// consumers (logs, structured-error sinks, the seed driver's
325    /// classifier) can extract the alias pairs and the summary string
326    /// without reparsing.
327    #[error("identifiability audit refused the fit: {}", audit.summary)]
328    IdentifiabilityFailure { audit: IdentifiabilityAudit },
329    /// MAP estimate uniqueness condition `ker(J^T W J) ∩ ker(S) = {0}` is
330    /// violated.  A null direction of `J^T W J` carries zero penalty
331    /// curvature, so the posterior is flat along that direction and the
332    /// MAP is non-unique.  The structured [`MapUniquenessError`] names the
333    /// dominant block so the caller can add the missing penalty or remove
334    /// the unpenalised direction.
335    #[error("MAP estimate non-unique: {}", error)]
336    MapUniquenessFailure { error: MapUniquenessError },
337    /// A numerical verdict the inner solve reached AT ONE TRIAL POINT: no
338    /// Laplace mode here, this active face's curvature refuses certification
339    /// here, this quadratic subproblem is degenerate here.
340    ///
341    /// Like [`Self::InnerSolveNotConverged`] this is a statement about one
342    /// `theta`, not about the problem — an indefinite coefficient point at one
343    /// rho is an ordinary Laplace mode at another — so the outer search should
344    /// reject the trial and step away, which is what the inner solver's own
345    /// logs say should happen. It is a separate variant because
346    /// `InnerSolveNotConverged` carries a fixed cycles/theta_dim/rho_dim/psi_dim
347    /// shape and a message specifically about refusing to expose profile
348    /// derivatives; reusing it for a curvature refusal would state something
349    /// untrue.
350    #[error("inner solve refused this trial point: {reason}")]
351    TrialPointRefused { reason: String },
352}
353
354impl CustomFamilyError {
355    /// A numerical refusal raised while evaluating at one trial point.
356    ///
357    /// The named constructor exists so a boundary that *knows* it is reporting
358    /// a rho-local failure can say so, rather than leaning on the blanket
359    /// `From<String>` below and hoping its default is right.
360    pub fn trial_point(reason: impl Into<String>) -> Self {
361        Self::TrialPointRefused {
362            reason: reason.into(),
363        }
364    }
365
366    /// Grade an already-typed error rho-local WITHOUT re-wrapping one that
367    /// already says so.
368    ///
369    /// A boundary whose whole contract is "evaluate at this rho" answers the
370    /// trial-point question for everything that crosses it (see the
371    /// [`From<String>`] rationale below and gam#2590). Doing that with
372    /// [`Self::trial_point`] on a value that is *already* a
373    /// [`Self::TrialPointRefused`] renders the inner error to text and prefixes
374    /// it a second time, which is how
375    ///
376    /// ```text
377    /// inner solve refused this trial point: inner solve refused this trial
378    ///   point: synthetic outer objective failure: block[0] evaluate()
379    /// ```
380    ///
381    /// reached a user (gam#2667). The doubled prefix was cosmetic; the loss it
382    /// made visible is not, because rendering to `String` discards the variant
383    /// and only [`From<String>`]'s default put a classification back.
384    ///
385    /// So: keep the error untouched when it already answers the question
386    /// (`is_trial_point_infeasible()`), and only render one that does not --
387    /// which is the single case where the classification is genuinely being
388    /// *changed* rather than restated.
389    #[must_use]
390    pub fn into_trial_point(self) -> Self {
391        if self.is_trial_point_infeasible() {
392            self
393        } else {
394            Self::TrialPointRefused {
395                reason: self.to_string(),
396            }
397        }
398    }
399}
400
401impl From<String> for CustomFamilyError {
402    /// # Why this lands on `TrialPointRefused` and not `InvalidInput`
403    ///
404    /// A `String` cannot carry the one bit the outer smoothing search needs —
405    /// is this failure a property of the trial point, or of the problem? — so
406    /// any conversion from it must answer by default. This one used to answer
407    /// `InvalidInput`, the variant [`Self::is_trial_point_infeasible`] returns
408    /// `false` for, and gam-custom-family's inner solver reports *every*
409    /// refusal as `Err(String)`. So "there is no Laplace mode at this rho", a
410    /// verdict about one rho, was graded fatal and killed the whole fit at the
411    /// first probe, at an optimizer whose seed loop has the correct branch one
412    /// line above the one it took (gam#2590).
413    ///
414    /// The default is not a coin flip, because the two mistakes are not
415    /// comparable:
416    ///
417    /// * A structural failure graded rho-local recurs at every probed rho. The
418    ///   seed loop exhausts, the run still fails, and it fails quoting this
419    ///   same reason — after a bounded number of cheap, identical inner
420    ///   failures.
421    /// * A rho-local refusal graded structural aborts a fit that was
422    ///   perfectly fittable one rho away. Measured twice: #2553, #2590.
423    ///
424    /// So where the type system forces a guess, the guess must be
425    /// "trial point". Where a caller knows better in either direction, it
426    /// should construct the variant it means — [`Self::trial_point`] or the
427    /// structural variant — instead of routing through here.
428    fn from(value: String) -> Self {
429        Self::TrialPointRefused { reason: value }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn regrading_a_trial_point_refusal_does_not_prefix_it_twice_2667() {
439        let inner = CustomFamilyError::trial_point(
440            "synthetic outer objective failure: block[0] evaluate()",
441        );
442        // The historical route: render to `String` at an internal boundary,
443        // then let the boundary answer the trial-point question again.
444        let round_tripped = CustomFamilyError::trial_point(inner.to_string());
445        assert_eq!(
446            round_tripped
447                .to_string()
448                .matches("inner solve refused this trial point:")
449                .count(),
450            2,
451            "fixture must reproduce the doubling this test is about"
452        );
453
454        // The typed route says the same thing once.
455        let regraded = inner.clone().into_trial_point();
456        assert_eq!(
457            regraded
458                .to_string()
459                .matches("inner solve refused this trial point:")
460                .count(),
461            1,
462            "an error that already answers the question must not be re-wrapped: {regraded}"
463        );
464        assert_eq!(regraded.to_string(), inner.to_string());
465        assert!(regraded.is_trial_point_infeasible());
466
467        // An error that does NOT answer the question is genuinely reclassified,
468        // and keeps its own text as the reason.
469        let structural = CustomFamilyError::DimensionMismatch {
470            reason: "log-lambda length mismatch: got 3, expected 4".to_string(),
471        };
472        let structural_text = structural.to_string();
473        let regraded = structural.into_trial_point();
474        assert!(regraded.is_trial_point_infeasible());
475        assert!(
476            regraded.to_string().contains(&structural_text),
477            "reclassification must not drop the original text: {regraded}"
478        );
479    }
480
481    #[test]
482    fn two_absences_are_not_reported_as_a_comparison_2600() {
483        // `|r|_inf=None against tol=None` reads as a measurement that came out
484        // badly. Nothing was measured, and the message has to say which side is
485        // missing: "no diagnostic on this path" and "the residual is 4e5x tol"
486        // call for different next steps.
487        let absent = CustomFamilyError::InnerSolveNotConverged {
488            cycles: 53,
489            terminal: None,
490            kkt_residual: None,
491            kkt_tol: None,
492            theta_dim: 3,
493            rho_dim: 3,
494            psi_dim: 0,
495        };
496        let msg = absent.to_string();
497        assert!(
498            !msg.contains("None against"),
499            "two absences must not be laid out as a comparison: {msg}"
500        );
501        assert!(
502            msg.contains("emits no typed projected-KKT diagnostic"),
503            "the message must name the absence as an absence: {msg}"
504        );
505
506        // With both present it still reads as the comparison it is.
507        let measured = CustomFamilyError::InnerSolveNotConverged {
508            cycles: 53,
509            terminal: None,
510            kkt_residual: Some(1.906428e0),
511            kkt_tol: Some(8.307952e-4),
512            theta_dim: 3,
513            rho_dim: 3,
514            psi_dim: 0,
515        };
516        let msg = measured.to_string();
517        assert!(
518            msg.contains("|r|_inf=1.906428e0 against tol=8.307952e-4"),
519            "a real comparison must still render as one: {msg}"
520        );
521
522        // A half-present pair names WHICH half is missing rather than printing
523        // `Some(..)`/`None` and leaving the reader to work it out.
524        let half = CustomFamilyError::InnerSolveNotConverged {
525            cycles: 7,
526            terminal: None,
527            kkt_residual: Some(4.069e3),
528            kkt_tol: None,
529            theta_dim: 1,
530            rho_dim: 1,
531            psi_dim: 0,
532        };
533        let msg = half.to_string();
534        assert!(
535            msg.contains("no stationarity tolerance was recorded"),
536            "a half-present pair must name the missing half: {msg}"
537        );
538    }
539
540    #[test]
541    fn joint_newton_terminal_state_reports_the_best_residual_not_only_the_last_2600() {
542        // The #2600 shape: a solve that reached 1.578e-3 (within 1.9x of tol)
543        // and then drifted for 27 cycles to a terminal 1.906e0. A reader given
544        // only the terminal value concludes the solve never approached
545        // stationarity; the correct reading is that it did and left. Both
546        // numbers and the distance back to the best one must be in the message.
547        let state = InnerConvergenceTerminalState::JointNewton {
548            cycle: 52,
549            stationarity_residual: 1.906428e0,
550            residual_tol: 8.307952e-4,
551            // Consistent with the pair above: `tol = 1e-6 · (1 + scale)`.
552            stationarity_scale: 829.7952,
553            step_inf: 4.958893e0,
554            step_tol: 8.493315e-5,
555            resolvable_negative_curvature: true,
556            best_stationarity_residual: 1.578e-3,
557            cycles_since_best_residual: 27,
558            termination_reason: JointNewtonTerminalReason::CycleBudget,
559        };
560        let msg = state.to_string();
561        assert!(
562            msg.contains("stationarity_residual=1.906428e0"),
563            "message: {msg}"
564        );
565        assert!(
566            msg.contains("best_stationarity_residual=1.578000e-3"),
567            "message: {msg}"
568        );
569        assert!(
570            msg.contains("27 cycle(s) before this one"),
571            "message: {msg}"
572        );
573    }
574
575    #[test]
576    fn joint_newton_terminal_state_carries_the_column_that_ranks_correctly_2713() {
577        // gam#2713: two refusals from one survival sweep, one per `inner_tol`
578        // lane. Read as "N x over tolerance" — the only ratio the message used
579        // to permit — they are ordered BACKWARDS relative to how converged they
580        // are, so triage goes to the wrong row. The message must therefore
581        // carry the denominator that fixes the ordering.
582        //
583        // A: derivative lane, `inner_tol = 1e-11`, scale = 43.807. Stationary
584        //    to nine digits — converged — and it prints `R/T = 238x`.
585        let converged = InnerConvergenceTerminalState::JointNewton {
586            cycle: 12,
587            stationarity_residual: 1.065281e-7,
588            residual_tol: 1e-11 * (1.0 + 43.807),
589            stationarity_scale: 43.807,
590            step_inf: 1.0e-9,
591            step_tol: 1.0e-10,
592            resolvable_negative_curvature: false,
593            best_stationarity_residual: 1.065281e-7,
594            cycles_since_best_residual: 0,
595            termination_reason: JointNewtonTerminalReason::CycleBudget,
596        };
597        // B: default lane, `inner_tol = 1e-6`, scale = 3.3392. A MILLION times
598        //    less converged than A, and it prints the larger `R/T`.
599        let far = InnerConvergenceTerminalState::JointNewton {
600            cycle: 12,
601            stationarity_residual: 1.4e-3 * (1.0 + 3.3392),
602            residual_tol: 1e-6 * (1.0 + 3.3392),
603            stationarity_scale: 3.3392,
604            step_inf: 1.0e-3,
605            step_tol: 1.0e-6,
606            resolvable_negative_curvature: false,
607            best_stationarity_residual: 1.4e-3 * (1.0 + 3.3392),
608            cycles_since_best_residual: 0,
609            termination_reason: JointNewtonTerminalReason::CycleBudget,
610        };
611
612        let (r_a, t_a, s_a) = (1.065281e-7, 1e-11 * (1.0 + 43.807), 43.807);
613        let (r_b, t_b, s_b) = (1.4e-3 * (1.0 + 3.3392), 1e-6 * (1.0 + 3.3392), 3.3392);
614
615        // The ratio a reader forms from the printed pair ranks A ABOVE B.
616        assert!(
617            r_a / t_a > 200.0 && r_b / t_b > 1000.0,
618            "the two rows must reproduce the measured N x over tolerance values"
619        );
620        assert!(
621            r_a / t_a < r_b / t_b,
622            "sanity: both rows are 'over tolerance', and by that ratio they are \
623             only ~6x apart"
624        );
625
626        // The comparable column ranks them the other way round, by six orders
627        // of magnitude: A is converged, B is not.
628        let rel_a = relative_stationarity(r_a, s_a);
629        let rel_b = relative_stationarity(r_b, s_b);
630        assert!(
631            rel_a < 1e-8 && rel_b > 1e-4,
632            "relative stationarity: A={rel_a:.3e} must be the converged row, \
633             B={rel_b:.3e} the unconverged one"
634        );
635        assert!(
636            rel_b / rel_a > 1e5,
637            "the two rows differ by five-plus orders in relative stationarity \
638             ({rel_a:.3e} vs {rel_b:.3e}) while their printed R/T differ by ~6x"
639        );
640
641        // ...and it is IN the message, for both, so no reader has to recover a
642        // scale by inverting the tolerance formula.
643        for (state, expected) in [(converged, rel_a), (far, rel_b)] {
644            let msg = state.to_string();
645            assert!(
646                msg.contains(&format!("relative_stationarity={expected:.6e}")),
647                "message must print the comparable column: {msg}"
648            );
649            assert!(
650                msg.contains("scale="),
651                "message must print the denominator it used: {msg}"
652            );
653        }
654    }
655
656    /// The scale-free end of [`relative_stationarity`]: with no scale to be
657    /// relative to, the criterion is the absolute residual against `inner_tol`,
658    /// NOT a division by zero.
659    #[test]
660    fn relative_stationarity_degrades_to_the_absolute_residual_at_zero_scale_2713() {
661        let absolute = relative_stationarity(3.7e-9, 0.0);
662        assert!(
663            absolute.to_bits() == 3.7e-9_f64.to_bits(),
664            "with no scale the criterion is the absolute residual, got {absolute:.6e}"
665        );
666        // And it agrees with the bare `R/scale` to within `1/scale` once there
667        // IS a scale, so nothing is lost at the end where the relative reading
668        // is the meaningful one.
669        let (residual, scale) = (5.275447e0, 5.2754e6);
670        let mixed = relative_stationarity(residual, scale);
671        let bare = residual / scale;
672        assert!(
673            ((mixed - bare) / bare).abs() < 1e-5,
674            "mixed={mixed:.6e} bare={bare:.6e}"
675        );
676    }
677
678    #[test]
679    fn invalid_input_display_contains_context_and_reason() {
680        let err = CustomFamilyError::InvalidInput {
681            context: "my_context",
682            reason: "something broke".to_string(),
683        };
684        let msg = err.to_string();
685        assert!(msg.contains("my_context"), "message: {msg}");
686        assert!(msg.contains("something broke"), "message: {msg}");
687    }
688
689    #[test]
690    fn optimization_display_contains_context_and_reason() {
691        let err = CustomFamilyError::Optimization {
692            context: "outer_loop",
693            reason: "diverged".to_string(),
694        };
695        let msg = err.to_string();
696        assert!(
697            msg.contains("outer_loop") && msg.contains("diverged"),
698            "message: {msg}"
699        );
700    }
701
702    #[test]
703    fn dimension_mismatch_displays_reason() {
704        let err = CustomFamilyError::DimensionMismatch {
705            reason: "3 vs 4".to_string(),
706        };
707        assert_eq!(err.to_string(), "3 vs 4");
708    }
709
710    #[test]
711    fn numerical_failure_displays_reason() {
712        let err = CustomFamilyError::NumericalFailure {
713            reason: "NaN detected".to_string(),
714        };
715        assert_eq!(err.to_string(), "NaN detected");
716    }
717
718    #[test]
719    fn a_string_boundary_refusal_is_recoverable_not_invalid_input() {
720        // The regression this exists for (gam#2590): the refusal used to
721        // arrive as `InvalidInput`, which classifies fatal, so an outer
722        // optimizer explicitly built to step away from an infeasible trial
723        // point aborted the whole fit at the first one it met.
724        let err = CustomFamilyError::from("no Laplace mode at this rho".to_string());
725        assert!(matches!(err, CustomFamilyError::TrialPointRefused { .. }));
726        assert!(err.is_trial_point_infeasible());
727        assert!(err.to_string().contains("no Laplace mode at this rho"));
728        assert_eq!(
729            CustomFamilyError::trial_point("x").to_string(),
730            CustomFamilyError::from("x".to_string()).to_string(),
731            "the named constructor and the blanket conversion must agree"
732        );
733        assert!(
734            !CustomFamilyError::InvalidInput {
735                context: "c",
736                reason: "r".to_string(),
737            }
738            .is_trial_point_infeasible(),
739            "`InvalidInput` must keep meaning what it says"
740        );
741    }
742
743    /// #2689 deleted `impl From<CustomFamilyError> for String` so that a
744    /// flattening is a compile error rather than a silent default. This test
745    /// used to assert that impl and so could not compile once it was gone.
746    ///
747    /// The behaviour worth keeping is what the impl *delegated to*: rendering
748    /// goes through `Display`, and an explicit `.to_string()` at a boundary
749    /// that genuinely owns a `String` contract must still produce the reason
750    /// verbatim. Asserting `Display` keeps that guarantee while leaving the
751    /// flattening un-resurrectable.
752    #[test]
753    fn rendering_a_custom_family_error_uses_display() {
754        let err = CustomFamilyError::NumericalFailure {
755            reason: "singular".to_string(),
756        };
757        assert_eq!(err.to_string(), "singular");
758    }
759}
760
761impl CustomFamilyError {
762    /// Whether a failure of this kind invalidates the whole outer run or
763    /// only the trial point it was produced at.
764    ///
765    /// The producer's judgement, made once against the variant. It
766    /// replaces a downstream substring match on the rendered message that
767    /// classified one variant two different ways depending on which call
768    /// site it crossed (#2553).
769    ///
770    /// The match is deliberately exhaustive with no wildcard arm: a new
771    /// variant must be classified when it is added, rather than
772    /// defaulting to whichever answer happens to be listed last.
773    #[must_use]
774    pub fn is_trial_point_infeasible(&self) -> bool {
775        match self {
776            // The inner solve missed its KKT condition at THIS theta. The
777            // outer search can step away; the problem is fine.
778            Self::InnerSolveNotConverged { .. } => true,
779            // Likewise rho-local: a numerical refusal evaluated at one trial
780            // point, which becomes true or false by moving theta (gam#2590).
781            Self::TrialPointRefused { .. } => true,
782            // Everything else is a property of the configuration, the
783            // data, or the numerics, and does not become true or false by
784            // moving theta.
785            Self::InvalidInput { .. }
786            | Self::Optimization { .. }
787            | Self::DimensionMismatch { .. }
788            | Self::NumericalFailure { .. }
789            | Self::ConstraintViolation { .. }
790            | Self::UnsupportedConfiguration { .. }
791            | Self::BasisDecompositionFailed { .. }
792            | Self::IdentifiabilityFailure { .. }
793            | Self::MapUniquenessFailure { .. } => false,
794        }
795    }
796}