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}