Skip to main content

gam_solve/rho_optimizer/
capability.rs

1use super::*;
2
3/// Declares what a specific model path can provide to the outer optimizer.
4///
5/// Each call site that optimizes smoothing parameters constructs one of these
6/// to describe its analytic derivative coverage. The [`plan`] function then
7/// selects the optimizer and Hessian strategy.
8///
9/// HISTORY: this crossover used to be 8 — a "performance choice" that routed
10/// every small-dimensional problem WITH an analytic gradient to BFGS on the
11/// theory that a dense quasi-Newton is cheaper below the cutoff. On the
12/// criteria that actually fail (the SAE manifold Laplace evidence: 2–7 ρ
13/// coordinates, piecewise-smooth basin-envelope value, inner-solve truncation
14/// noise), BFGS is not cheaper — its Strong-Wolfe line search is the consumer
15/// of the entire probe-lane / wall / escape / rescue apparatus, and every cost
16/// probe is a full inner re-convergence. EFS is the declared canonical REML
17/// method, needs only the traces `tr(H⁻¹S_k)` (no line search, no Wolfe, no
18/// value/gradient-lane agreement), and already drives every large fit. The
19/// crossover is therefore 0: a fixed-point-capable objective routes to
20/// EFS/HybridEfs at EVERY dimension, and BFGS remains the fallback for
21/// objectives with no fixed-point hook (or after `disable_fixed_point`).
22pub(crate) const SMALL_OUTER_BFGS_MAX_PARAMS: usize = 0;
23
24pub(crate) const SECOND_ORDER_GEOMETRY_PROBE_MAX_PARAMS: usize = 64;
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct OuterThetaLayout {
28    pub n_params: usize,
29    pub psi_dim: usize,
30}
31
32impl OuterThetaLayout {
33    pub const fn new(n_params: usize, psi_dim: usize) -> Self {
34        Self { n_params, psi_dim }
35    }
36
37    pub const fn rho_dim(&self) -> usize {
38        self.n_params.saturating_sub(self.psi_dim)
39    }
40
41    /// Does outer coordinate `k` parameterize a smoothing parameter as
42    /// `λ = e^ρ`?
43    ///
44    /// The θ vector is one contiguous array under one box, and every layer
45    /// that reasons about a *bound* — the projector, the active set, the PSD
46    /// sub-block — is right to treat all of it alike: a box constraint is a
47    /// statement about a coordinate, not about what the coordinate means.
48    ///
49    /// The asymptote/tail law is not that kind of rule. `ĉ = ∓e^{±ρ}·∂V/∂ρ`
50    /// is derived from `λ = e^ρ` together with the REML criterion's `O(1/λ)`
51    /// behaviour, and it exists because the ρ box is a **proxy** for a limit
52    /// the search can never actually reach: `λ = ∞` is at `ρ = ∞`, so the
53    /// remaining gap to the optimum has to be extrapolated rather than
54    /// observed. A ψ coordinate is a different quantity — a sectional
55    /// curvature, a log length-scale, a measure exponent — and its box is a
56    /// **real** constraint (chart validity, `s ∈ (0, 2)`) whose endpoint a
57    /// point can sit exactly on. There the ordinary complementarity the
58    /// projector already applies IS the first-order certificate, and
59    /// exponentiating the coordinate has no derivation at all (#2453).
60    ///
61    /// Answering this question by position is exactly what makes it a
62    /// question about the quantity: `psi_dim` is declared by the call site
63    /// that knows what it put in each slot, and the trailing block is the ψ
64    /// block by construction.
65    pub const fn coordinate_is_log_smoothing(&self, k: usize) -> bool {
66        k < self.rho_dim()
67    }
68
69    fn validate_capability(&self, context: &str) -> Result<(), EstimationError> {
70        if self.psi_dim > self.n_params {
71            return Err(EstimationError::RemlOptimizationFailed(format!(
72                "{context}: invalid outer theta layout (psi_dim={} exceeds n_params={})",
73                self.psi_dim, self.n_params
74            )));
75        }
76        Ok::<(), _>(())
77    }
78
79    pub(crate) fn validate_point_len(
80        &self,
81        theta: &Array1<f64>,
82        context: &str,
83    ) -> Result<(), ObjectiveEvalError> {
84        if theta.len() != self.n_params {
85            return Err(ObjectiveEvalError::fatal(format!(
86                "{context}: outer theta length mismatch: got {}, expected {} (rho_dim={}, psi_dim={})",
87                theta.len(),
88                self.n_params,
89                self.rho_dim(),
90                self.psi_dim
91            )));
92        }
93        Ok::<(), _>(())
94    }
95
96    pub(crate) fn validate_gradient_len(
97        &self,
98        gradient: &Array1<f64>,
99        context: &str,
100    ) -> Result<(), ObjectiveEvalError> {
101        if gradient.len() != self.n_params {
102            return Err(ObjectiveEvalError::fatal(format!(
103                "{context}: outer gradient length mismatch: got {}, expected {} (rho_dim={}, psi_dim={})",
104                gradient.len(),
105                self.n_params,
106                self.rho_dim(),
107                self.psi_dim
108            )));
109        }
110        Ok::<(), _>(())
111    }
112
113    pub(crate) fn validate_hessian_shape(
114        &self,
115        hessian: &Array2<f64>,
116        context: &str,
117    ) -> Result<(), ObjectiveEvalError> {
118        if hessian.nrows() != self.n_params || hessian.ncols() != self.n_params {
119            return Err(ObjectiveEvalError::fatal(format!(
120                "{context}: outer Hessian shape mismatch: got {}x{}, expected {}x{} (rho_dim={}, psi_dim={})",
121                hessian.nrows(),
122                hessian.ncols(),
123                self.n_params,
124                self.n_params,
125                self.rho_dim(),
126                self.psi_dim
127            )));
128        }
129        Ok::<(), _>(())
130    }
131
132    pub(crate) fn validate_efs_eval(
133        &self,
134        eval: &EfsEval,
135        context: &str,
136    ) -> Result<(), ObjectiveEvalError> {
137        if eval.steps.len() != self.n_params {
138            return Err(ObjectiveEvalError::recoverable(format!(
139                "{context}: outer EFS step length mismatch: got {}, expected {} (rho_dim={}, psi_dim={})",
140                eval.steps.len(),
141                self.n_params,
142                self.rho_dim(),
143                self.psi_dim
144            )));
145        }
146        if let Some(ref psi_gradient) = eval.psi_gradient
147            && psi_gradient.len() != self.psi_dim
148        {
149            return Err(ObjectiveEvalError::recoverable(format!(
150                "{context}: outer EFS psi-gradient length mismatch: got {}, expected {}",
151                psi_gradient.len(),
152                self.psi_dim
153            )));
154        }
155        if let Some(ref psi_indices) = eval.psi_indices {
156            if psi_indices.len() != self.psi_dim {
157                return Err(ObjectiveEvalError::recoverable(format!(
158                    "{context}: outer EFS psi-index count mismatch: got {}, expected {}",
159                    psi_indices.len(),
160                    self.psi_dim
161                )));
162            }
163            if psi_indices.iter().any(|&idx| idx >= self.n_params) {
164                return Err(ObjectiveEvalError::recoverable(format!(
165                    "{context}: outer EFS psi index out of range for n_params={}",
166                    self.n_params
167                )));
168            }
169        }
170        Ok(())
171    }
172}
173
174#[derive(Clone, Debug)]
175pub struct OuterCapability {
176    pub gradient: Derivative,
177    /// Declared shape of the analytic Hessian (or its absence). Replaces
178    /// the binary `Derivative` so the planner can route between dense
179    /// ARC and matrix-free trust-region *before* seed evaluation. See
180    /// [`DeclaredHessianForm`].
181    pub hessian: DeclaredHessianForm,
182    /// Number of smoothing (+ any auxiliary hyper-) parameters being optimized.
183    pub n_params: usize,
184    /// Number of ψ (design-moving) coordinates among the extended
185    /// hyperparameter coordinates. When 0, all coords are penalty-like and
186    /// pure EFS is eligible (given `fixed_point_available`). When > 0,
187    /// hybrid EFS is eligible instead: EFS for ρ + preconditioned gradient
188    /// for ψ.
189    ///
190    /// # Hybrid EFS strategy (when `psi_dim > 0`)
191    ///
192    /// Enabled when `psi_dim > 0`, `fixed_point_available`, and either the
193    /// analytic gradient is unavailable or the problem is above the small-
194    /// dimensional BFGS crossover.
195    /// Combines:
196    /// - Standard EFS multiplicative fixed-point updates for ρ coordinates
197    /// - Safeguarded preconditioned gradient steps for ψ coordinates:
198    ///   `Δψ = -α G⁺ g_ψ` where G is the trace Gram matrix
199    ///
200    /// Mathematically necessary because no EFS-type fixed-point iteration
201    /// exists for indefinite B_ψ (see response.md Section 2). The structural
202    /// requirement for EFS is `H^{-1/2} B_d H^{-1/2} ≽ 0` (PSD) plus fixed
203    /// nullspace — exactly what penalty-like coords satisfy and design-moving
204    /// coords do not.
205    ///
206    /// The hybrid is O(1) H⁻¹ solves per iteration (same as pure EFS),
207    /// compared to O(dim(θ)) for BFGS.
208    pub psi_dim: usize,
209    /// Whether the objective actually implements `eval_efs()` for fixed-point
210    /// plans. Structural eligibility (`psi_dim == 0` / `psi_dim > 0`)
211    /// is not sufficient by itself: if this is false, the planner must stay on
212    /// Newton/BFGS-style plans even when EFS or Hybrid-EFS would otherwise be
213    /// mathematically admissible.
214    pub fixed_point_available: bool,
215    /// Optional log-barrier configuration for structural monotonicity constraints.
216    /// When present, EFS is still eligible at plan time, but the EFS iteration
217    /// loop performs a quantitative check each step: if
218    /// `barrier_curvature_is_significant(β, ref_diag, threshold)` fires, EFS
219    /// is abandoned and the fallback ladder routes to a first-order joint
220    /// optimizer.
221    ///
222    /// Previously this was a binary `barrier_active: bool` that unconditionally
223    /// blocked EFS. The quantitative check allows EFS when constraints exist but
224    /// the barrier curvature is negligible (coefficients far from their bounds).
225    pub barrier_config: Option<BarrierConfig>,
226    /// Reserve analytic Hessian work for the terminal mint certificate.
227    ///
228    /// The generic REML/LAML Hessian consumes the row-family derivative ladder
229    /// through order four, while its analytic gradient stops at order three.
230    /// When this is true, search therefore uses analytic-gradient BFGS and the
231    /// exact Hessian remains declared for the one terminal
232    /// `ValueGradientHessian` certification. Closed-form objectives whose
233    /// Hessian does not consume an order-four family tower may set this false
234    /// and use ARC during search.
235    pub prefer_gradient_only: bool,
236    /// Policy hint: even when the objective implements `eval_efs()` and the
237    /// coordinate structure is penalty-like, the planner must NOT select
238    /// EFS/HybridEfs for this problem.
239    ///
240    /// Set by the caller for problem classes where the Wood-Fasiolo structural
241    /// property (`H^{-1/2} B_k H^{-1/2} ≽ 0` plus parameter-independent
242    /// nullspace) is known not to hold — e.g. GAMLSS/location-scale families
243    /// where the joint Hessian is β-dependent and cross-block smoothers
244    /// induce non-diagonal curvature that the EFS multiplicative fixed-point
245    /// cannot resolve. Also set by the automatic fallback cascade when an
246    /// EFS/HybridEfs attempt failed to converge, so the next attempt falls
247    /// back to analytic-gradient BFGS rather than retrying EFS.
248    pub disable_fixed_point: bool,
249}
250
251impl OuterCapability {
252    pub const fn theta_layout(&self) -> OuterThetaLayout {
253        OuterThetaLayout::new(self.n_params, self.psi_dim)
254    }
255
256    pub fn validate_layout(&self, context: &str) -> Result<(), EstimationError> {
257        self.theta_layout().validate_capability(context)
258    }
259
260    /// True when all coordinates are penalty-like (no ψ coords).
261    pub const fn all_penalty_like(&self) -> bool {
262        self.psi_dim == 0
263    }
264    /// True when ψ (design-moving) coordinates are present.
265    pub const fn has_psi_coords(&self) -> bool {
266        self.psi_dim > 0
267    }
268
269    fn efs_plan_eligible(&self) -> bool {
270        self.fixed_point_available
271            && !self.disable_fixed_point
272            && self.all_penalty_like()
273            // A fixed-point-capable objective routes to EFS at every dimension
274            // (see `SMALL_OUTER_BFGS_MAX_PARAMS`): the former ≤8-coordinate
275            // BFGS crossover sent exactly the failing small fits into the
276            // fragile Wolfe/probe lane while large fits got the robust
277            // trace-based fixed point.
278            && (self.gradient == Derivative::Unavailable
279                || self.n_params > SMALL_OUTER_BFGS_MAX_PARAMS)
280    }
281
282    fn hybrid_efs_plan_eligible(&self) -> bool {
283        self.fixed_point_available
284            && !self.disable_fixed_point
285            && self.has_psi_coords()
286            && (self.gradient == Derivative::Unavailable
287                || self.n_params > SMALL_OUTER_BFGS_MAX_PARAMS)
288    }
289
290    fn declared_hessian_for_planning(&self) -> Derivative {
291        if self.hessian.is_analytic() {
292            Derivative::Analytic
293        } else {
294            Derivative::Unavailable
295        }
296    }
297}
298
299/// Which solver algorithm to use for the outer optimization.
300#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301pub enum Solver {
302    /// Adaptive Regularized Cubic; fastest convergence, requires Hessian.
303    Arc,
304    /// BFGS; gradient only, builds a dense curvature approximation.
305    Bfgs,
306    /// Extended Fellner-Schall; multiplicative fixed-point iteration.
307    /// Only valid when all hyperparameter coordinates are penalty-like.
308    /// Needs no gradient or Hessian — only traces tr(H^{-1} A_k) and
309    /// Frobenius norms from the inner solution.
310    Efs,
311    /// Hybrid EFS + preconditioned gradient.
312    ///
313    /// Used when ψ (design-moving) coordinates are present alongside ρ
314    /// (penalty-like) coordinates. Combines:
315    /// - Standard EFS multiplicative fixed-point steps for ρ coords
316    /// - Safeguarded preconditioned gradient steps for ψ coords:
317    ///   `Δψ = -α G⁺ g_ψ` where `G_{de} = tr(H⁻¹ B_d H⁻¹ B_e)`
318    ///
319    /// This hybrid exists because no EFS-type fixed-point iteration can
320    /// guarantee convergence for indefinite B_ψ (proven by counterexample
321    /// in response.md Section 2). The key structural property that EFS
322    /// needs — `H^{-1/2} B_d H^{-1/2} ≽ 0` plus parameter-independent
323    /// nullspace — holds for penalty-like coords but fails for
324    /// design-moving coords where B_ψ has mixed inertia.
325    ///
326    /// The preconditioned gradient uses the same trace Gram matrix that
327    /// EFS already computes, so the cost is O(1) H⁻¹ solves per iteration
328    /// (same as pure EFS), compared to O(dim(θ)) for full BFGS.
329    HybridEfs,
330}
331
332/// How the Hessian will be obtained for the outer optimizer.
333#[derive(Clone, Copy, Debug, PartialEq, Eq)]
334pub enum HessianSource {
335    /// Exact analytic Hessian provided by the objective.
336    Analytic,
337    /// No explicit Hessian; BFGS builds a rank-2 approximation from
338    /// gradient history.
339    BfgsApprox,
340    /// No explicit Hessian or gradient needed. EFS uses traces and
341    /// Frobenius norms from the inner solution directly.
342    EfsFixedPoint,
343    /// Hybrid EFS + preconditioned gradient for ψ coordinates.
344    /// EFS traces for ρ coords, trace Gram matrix + gradient for ψ coords.
345    HybridEfsFixedPoint,
346}
347
348/// Requested derivative order for an outer objective evaluation.
349///
350/// This enum is for the shared `eval` bridge where the runner needs value-only,
351/// first-order, or second-order information depending on the active plan.
352///
353/// Single-sourced on the lower `gam-model-api` crate so the gam-models
354/// fit_orchestration drivers and the gam-solve runner share one type (#1521).
355pub use gam_model_api::OuterEvalOrder;
356
357/// The outer optimization plan. Produced by [`plan`], consumed by the runner.
358#[derive(Clone, Copy, Debug, PartialEq, Eq)]
359pub struct OuterPlan {
360    pub solver: Solver,
361    /// Curvature used by the search algorithm, not by the mandatory terminal
362    /// certificate. A gradient-only BFGS search can still be audited by the
363    /// objective's exact analytic Hessian at mint time (#2359, #2641).
364    pub hessian_source: HessianSource,
365}
366
367/// A fixed-point evaluator's typed request to hand the current outer attempt
368/// to a joint first-order solver.
369///
370/// This is control flow, not a trial-point failure category. It therefore
371/// travels as the source of an [`ObjectiveEvalError`] and is recognized by
372/// downcast; neither its wording nor any sentinel embedded in that wording can
373/// change which plan runs next.
374#[derive(Clone, Debug)]
375pub(crate) struct FirstOrderFallbackRequest {
376    reason: String,
377}
378
379impl FirstOrderFallbackRequest {
380    pub(crate) fn new(reason: impl Into<String>) -> Self {
381        Self {
382            reason: reason.into(),
383        }
384    }
385
386    pub(crate) fn reason(&self) -> &str {
387        &self.reason
388    }
389}
390
391impl std::fmt::Display for FirstOrderFallbackRequest {
392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        f.write_str(&self.reason)
394    }
395}
396
397impl std::error::Error for FirstOrderFallbackRequest {}
398
399pub(crate) fn first_order_fallback_error(reason: impl Into<String>) -> ObjectiveEvalError {
400    ObjectiveEvalError::recoverable_from(FirstOrderFallbackRequest::new(reason))
401}
402
403pub(crate) fn first_order_fallback_request(
404    error: &ObjectiveEvalError,
405) -> Option<&FirstOrderFallbackRequest> {
406    error.downcast_ref::<FirstOrderFallbackRequest>()
407}
408
409/// Whether outer_strategy should automatically derive a retry ladder from the
410/// primary capability, or disable retries entirely.
411#[derive(Clone, Copy, Debug, PartialEq, Eq)]
412pub enum FallbackPolicy {
413    /// Centralized retry path chosen from the declared capability.
414    Automatic,
415    /// No retries; use only the primary plan.
416    Disabled,
417}
418
419impl std::fmt::Display for OuterPlan {
420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421        write!(
422            f,
423            "solver={:?}, search_hessian_source={:?}",
424            self.solver, self.hessian_source
425        )
426    }
427}
428
429impl OuterPlan {
430    /// Stable, grep-friendly routing token for large-scale/log regression
431    /// assertions. Emits `solver=<Solver>;hessian=<Source>;matrix-free=<bool>`.
432    /// Planning alone does not prove the runtime Hessian representation;
433    /// matrix-free routing is decided after the seed evaluation returns an
434    /// operator Hessian, so the static plan token reports `false`.
435    pub fn routing_log_line(&self) -> String {
436        let matrix_free = false;
437        format!(
438            "solver={:?};hessian={:?};matrix-free={}",
439            self.solver, self.hessian_source, matrix_free
440        )
441    }
442}
443
444/// Select the outer optimization strategy from the declared capability.
445///
446/// This is a pure function with no side effects. All policy lives here.
447pub fn plan(cap: &OuterCapability) -> OuterPlan {
448    use Derivative as D;
449    use HessianSource as H;
450    use Solver as S;
451
452    match (cap.gradient, cap.declared_hessian_for_planning()) {
453        (D::Analytic, D::Analytic) if cap.prefer_gradient_only => OuterPlan {
454            solver: S::Bfgs,
455            hessian_source: H::BfgsApprox,
456        },
457        (D::Analytic, D::Analytic) => OuterPlan {
458            solver: S::Arc,
459            hessian_source: H::Analytic,
460        },
461        // EFS: all penalty-like coords and no analytic Hessian. With an
462        // analytic gradient this is the many-parameter fast path; without one
463        // it is the only declared analytic solver at any dimension.
464        // Multiplicative fixed-point needs only traces — no gradient evals.
465        // Much cheaper than BFGS for k=10-50 smoothing parameters.
466        //
467        // When a log-barrier is present (monotonicity constraints), EFS is
468        // still selected here. The EFS iteration loop in `run_outer` performs
469        // a quantitative check each step via `barrier_curvature_is_significant`
470        // and bails out early if the barrier curvature becomes non-negligible
471        // relative to the penalized Hessian diagonal.
472        (D::Analytic, D::Unavailable) if cap.efs_plan_eligible() => OuterPlan {
473            solver: S::Efs,
474            hessian_source: H::EfsFixedPoint,
475        },
476        (D::Unavailable, D::Unavailable) if cap.efs_plan_eligible() => OuterPlan {
477            solver: S::Efs,
478            hessian_source: H::EfsFixedPoint,
479        },
480
481        // Hybrid EFS: ψ (design-moving) coords present alongside ρ coords.
482        //
483        // When ψ coords are present, pure EFS is invalid because B_ψ can be
484        // indefinite (see response.md Section 2 for the counterexample). But
485        // falling back to full BFGS wastes the cheap EFS structure for ρ coords.
486        //
487        // The hybrid strategy uses EFS for ρ-coords and a safeguarded
488        // preconditioned gradient step for ψ-coords:
489        //   Δψ = -α G⁺ g_ψ,  G_{de} = tr(H⁻¹ B_d H⁻¹ B_e)
490        //
491        // This stays O(1) H⁻¹ solves per iteration (vs O(dim(θ)) for BFGS)
492        // and uses the same trace Gram matrix that EFS already computes.
493        (D::Analytic, D::Unavailable) if cap.hybrid_efs_plan_eligible() => OuterPlan {
494            solver: S::HybridEfs,
495            hessian_source: H::HybridEfsFixedPoint,
496        },
497        (D::Unavailable, D::Unavailable) if cap.hybrid_efs_plan_eligible() => OuterPlan {
498            solver: S::HybridEfs,
499            hessian_source: H::HybridEfsFixedPoint,
500        },
501
502        // Gradient-only problems should use a gradient-only optimizer.
503        (D::Analytic, D::Unavailable) => OuterPlan {
504            solver: S::Bfgs,
505            hessian_source: H::BfgsApprox,
506        },
507        // No analytic gradient (with or without a declared Hessian), and the
508        // EFS/HybridEFS fixed-point lane ruled out above. Every outer objective
509        // in the tree now supplies an analytic gradient, so a cost-only
510        // capability is a programming error. Emit a BFGS plan so it surfaces
511        // loudly with context: the runner rejects it because BFGS requires the
512        // analytic gradient this capability declares is absent. We deliberately
513        // do NOT invent a working primary here — a cost-only objective has no
514        // solver, by design.
515        (D::Unavailable, _) => OuterPlan {
516            solver: S::Bfgs,
517            hessian_source: H::BfgsApprox,
518        },
519    }
520}
521
522/// Log the outer optimization plan. Called once per fit at the start of
523/// outer optimization so the user can see what strategy was selected and why.
524pub fn log_plan(context: &str, cap: &OuterCapability, the_plan: &OuterPlan) {
525    let hess_warning = match the_plan.hessian_source {
526        HessianSource::BfgsApprox if cap.n_params > 0 => {
527            " [no Hessian: BFGS approximation]".to_string()
528        }
529        _ => String::new(),
530    };
531    let barrier_note = if cap.barrier_config.is_some() && cap.efs_plan_eligible() {
532        " [EFS with runtime barrier-curvature guard]"
533    } else {
534        ""
535    };
536    let hybrid_note = if the_plan.solver == Solver::HybridEfs {
537        " [hybrid EFS(ρ) + preconditioned-gradient(ψ)]"
538    } else {
539        ""
540    };
541    // Promoted to info: this fires once per outer optimization dispatch and
542    // tells the user immediately whether ARC, BFGS, EFS, etc. was selected
543    // and why. That information is otherwise inferred only from the per-iter
544    // log tag prefix once the loop has started.
545    log::info!(
546        "[OUTER] {context}: n_params={}, gradient={:?}, hessian={:?} -> {} [{}]{hess_warning}{barrier_note}{hybrid_note}",
547        cap.n_params,
548        cap.gradient,
549        cap.hessian,
550        the_plan,
551        the_plan.routing_log_line(),
552    );
553}
554
555/// Disable the EFS/HybridEfs planner path, forcing BFGS-class solvers on the
556/// next attempt. Returns `None` if fixed-point is already disabled.
557pub(crate) fn disable_fixed_point(cap: &OuterCapability) -> Option<OuterCapability> {
558    (!cap.disable_fixed_point && (cap.efs_plan_eligible() || cap.hybrid_efs_plan_eligible())).then(
559        || {
560            let mut degraded = cap.clone();
561            degraded.disable_fixed_point = true;
562            degraded
563        },
564    )
565}
566
567pub(crate) fn automatic_fallback_attempts(cap: &OuterCapability) -> Vec<OuterCapability> {
568    // Production fallback ladder is strictly analytic-gradient.
569    //
570    // The cascade is:
571    //   1. If the primary plan is EFS/HybridEFS AND an analytic gradient is
572    //      available, retry with fixed-point disabled so the analytic
573    //      derivative declaration is evaluated directly.
574    //   2. If the primary plan is Arc (declared (Analytic, Analytic)
575    //      capability), do NOT add a degraded fallback. Demoting to
576    //      BFGS+BfgsApprox in this case discards the analytic outer Hessian
577    //      ARC was using — a strictly weaker geometry — and silently masks
578    //      ARC's actual failure mode (e.g. budget exhaustion, indefinite
579    //      curvature) under a BFGS Strong-Wolfe plateau on a flat surface.
580    //      ARC retries are handled by the per-attempt budget-bump retry
581    //      ladder in `run_outer_with_strategy`; once that is exhausted, the
582    //      caller surfaces the underlying ARC failure verbatim.
583    //   3. Otherwise (e.g. (Analytic, Unavailable) without EFS eligibility,
584    //      which is the BFGS primary), there is nothing to degrade further
585    //      — the caller surfaces the RemlOptimizationFailed error so the
586    //      non-convergence is visible.
587    let mut attempts = Vec::new();
588
589    if cap.gradient == Derivative::Analytic
590        && matches!(plan(cap).solver, Solver::Efs | Solver::HybridEfs)
591        && let Some(no_fp_cap) = disable_fixed_point(cap)
592    {
593        attempts.push(no_fp_cap.clone());
594        return attempts;
595    }
596
597    // Arc primary: no lateral demotion to BFGS. The runner's ARC-budget-bump
598    // retry covers cases where ARC needed more iterations; if even that is
599    // exhausted, the caller sees the genuine analytic-Hessian non-convergence
600    // rather than a misleading BFGS-on-flat-surface plateau.
601    if matches!(plan(cap).solver, Solver::Arc) {
602        return attempts;
603    }
604
605    attempts
606}
607
608pub(crate) fn disabled_fallback_hybrid_efs_has_standalone_bfgs_primary(
609    cap: &OuterCapability,
610    config: &OuterConfig,
611) -> bool {
612    config.fallback_policy == FallbackPolicy::Disabled
613        && cap.gradient == Derivative::Analytic
614        && matches!(plan(cap).solver, Solver::HybridEfs)
615}
616
617pub(crate) fn primary_capability_for_config(
618    mut cap: OuterCapability,
619    config: &OuterConfig,
620    context: &str,
621) -> OuterCapability {
622    if disabled_fallback_hybrid_efs_has_standalone_bfgs_primary(&cap, config) {
623        // HybridEFS is not a standalone first-order method for ψ coordinates:
624        // when ψ backtracking proves non-descent, the bridge intentionally
625        // surfaces a typed `FirstOrderFallbackRequest` so the runner can switch
626        // to a joint gradient solver that enforces ∇ψ V = 0. With fallback
627        // disabled and an analytic gradient available, selecting HybridEFS as
628        // the only primary attempt is internally inconsistent; BFGS is the
629        // standalone first-order primary for that capability.
630        log::info!(
631            "[OUTER] {context}: HybridEFS requires the automatic first-order \
632             escape path for ψ coordinates; fallback is disabled, so routing the \
633             primary attempt to analytic-gradient BFGS"
634        );
635        cap.disable_fixed_point = true;
636    }
637    cap
638}
639
640#[cfg(test)]
641mod typed_fallback_tests {
642    use super::*;
643
644    #[test]
645    fn first_order_fallback_routing_depends_on_source_type_not_message_2658() {
646        let typed = first_order_fallback_error("arbitrary human-readable reason");
647        let request =
648            first_order_fallback_request(&typed).expect("typed request must survive opt boundary");
649        assert_eq!(request.reason(), "arbitrary human-readable reason");
650
651        let marker_shaped_prose = ObjectiveEvalError::recoverable(
652            "[outer-efs-first-order-fallback] legacy-looking prose",
653        );
654        assert!(
655            first_order_fallback_request(&marker_shaped_prose).is_none(),
656            "prose must have no authority to select a solver"
657        );
658    }
659}