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