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}