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