Skip to main content

gam_solve/rho_optimizer/
objective.rs

1use super::*;
2use super::rail_face::RailFaceLimitOutcome;
3
4// Re-exported here while the shared EFS contract lives in `gam-problem`.
5pub use gam_problem::{EfsEval, FixedPointCertificateEval, FixedPointCoordinateCertificate};
6
7/// Outcome of [`OuterObjective::seed_inner_state`].
8///
9/// Distinguishes two non-error outcomes that callers handle differently:
10///
11/// - [`SeedOutcome::Installed`] — the objective owns an inner-β slot and the
12///   provided β has been stored there. The next `eval*` will warm-start from
13///   this β.
14/// - [`SeedOutcome::NoSlot`] — the objective has no inner-β slot at all. The
15///   provided β is silently discarded. This is the contract reply for
16///   objectives whose inner iterate is conceptually empty (e.g. line-search
17///   bridges, screening proxies, fixed-spec objectives).
18///
19/// Genuine seeding failures (wrong dimension when a slot exists, internal
20/// allocation faults, …) are reported via `Err(EstimationError)`.
21///
22/// The two non-error variants exist because the two real callers want
23/// opposite behavior on the no-slot path:
24///
25/// - The outer cache warm-start path (`OuterProblem::run`) reads a `(ρ, β)`
26///   pair from disk; if the objective has no β slot it must log loudly
27///   ("β-bearing checkpoint silently degraded to ρ-only resume") so cache
28///   provenance is auditable.
29/// - The typed reactive continuation path forwards `inner_beta_hint` from the
30///   previous solved waypoint; if the objective has no β slot the path
31///   simply proceeds cold — no log, no error.
32///
33/// Encoding the distinction in the return type lets each caller branch on
34/// the variant without inspecting error message strings (the previous
35/// brittle approach, see git history for `is_no_hook` in continuation.rs).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SeedOutcome {
38    /// The objective installed the provided β into its inner-β slot.
39    Installed,
40    /// The objective has no inner-β slot; the β was discarded.
41    NoSlot,
42    /// The objective owns an inner-β slot, but the provided β is
43    /// structurally incompatible with this fit's inner block layout
44    /// (its length does not match the per-block coefficient widths). The
45    /// β was discarded and the fit resumes ρ-only.
46    ///
47    /// This is the load-time reply for a *row-relaxed* cross-fit seed
48    /// (the `cache_seed_key` prefix channel): two folds of the same model
49    /// share an ρ-dim, so the cached ρ transfers, but the realized basis
50    /// rank — hence the inner β length — is row-population dependent and
51    /// legitimately differs across folds (the LOSO p=37-vs-p=85 case).
52    /// A length-mismatched seed β is therefore NOT an error: cross-length
53    /// β transfer is delegated to the gauge-projected `FitArtifact`
54    /// channel, which least-squares re-expresses the parent's raw β into
55    /// this fold's reduced subspace. Reporting `Incompatible` here keeps
56    /// the (correct) ρ seed and avoids a spurious full cold-start.
57    Incompatible,
58}
59
60/// Common interface for outer smoothing-parameter objectives.
61///
62/// Every model path that optimizes smoothing parameters implements this trait.
63/// The runner function consumes it and handles solver selection,
64/// multi-start, and logging while delegating derivative fallback policy to
65/// `opt`.
66///
67/// # Contract
68///
69/// - `capability()` must be stable (same result across calls).
70/// - `eval()` may return `HessianValue::Unavailable` at individual trial
71///   points even when `capability().hessian == Analytic`; `opt` degrades that
72///   step to first-order behavior instead of requiring the objective to fake a
73///   stale or non-finite Hessian.
74/// - Use `eval_cost()` / `OuterEval::infeasible()` for infeasible trial points.
75///   Return `Err(...)` only when the evaluation artifact itself cannot be
76///   constructed. Such errors are fatal across screening, multistart, and
77///   solver plans; they are never reinterpreted as another numerical trial.
78/// - `eval_cost()` is used only for cost-based optimization paths.
79/// - `eval()` is the main evaluation path (cost + gradient + optional Hessian).
80/// - `eval_efs()` is used only by the EFS solver. It runs the inner solve,
81///   builds the `InnerSolution`, and computes the EFS step vector. The default
82///   implementation returns an error; only objectives that support EFS need
83///   to override it.
84/// - `reset()` restores state to a clean baseline (for multi-start).
85pub trait OuterObjective {
86    /// Declare what this objective can compute analytically.
87    fn capability(&self) -> OuterCapability;
88
89    /// Evaluate cost only for cost-based optimization paths.
90    fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError>;
91
92    /// Evaluate the seed-screening ranking proxy at this `rho`.
93    ///
94    /// Used exclusively by the `rank_seeds_with_screening` cascade. The
95    /// default delegates to [`OuterObjective::eval_cost`], which preserves
96    /// behavior for non-REML objectives.
97    ///
98    /// Concrete REML-state objectives override this to return the per-seed
99    /// minimum penalized deviance observed during the inner P-IRLS solve
100    /// (a monotonically descending quantity that remains a meaningful
101    /// quality signal even at a 3-iteration screening cap), instead of the
102    /// V_LAML criterion (which is dominated by a poorly-conditioned
103    /// `0.5·log|H|` term at partial-fit β̂ and ranks seeds little better
104    /// than random). The proxy fires *only* in screening mode; outside
105    /// screening it must return the regular V_LAML cost so the optimization
106    /// objective is unchanged.
107    ///
108    /// # Why the `eval_cost` default is correct for everyone else (#969)
109    ///
110    /// The partial-fit pathology is CAUSED by the screening cap: it is the
111    /// `0.5·log|H|` term evaluated at a β̂ whose inner solve was truncated
112    /// by `screening_max_inner_iterations`. An objective only suffers it if
113    /// it (a) consumes that cap atomic AND (b) ranks on a curvature-bearing
114    /// criterion at the truncated iterate — which is exactly the REML/LAML
115    /// state-objective family, all of which override this method (or are
116    /// built via `build_objective_with_screening_proxy`). Objectives that
117    /// never wire the cap pay the full inner solve during screening, so
118    /// their screened cost IS the true criterion — slower, but a correct
119    /// ranking by definition, and a proxy could only degrade it. Any future
120    /// objective that starts honoring the screening cap on a
121    /// curvature-bearing criterion must override this with its own
122    /// monotonically-descending inner quantity (the penalized-deviance
123    /// pattern above generalizes: rank on the best inner merit seen, never
124    /// on a curvature term at a truncated iterate).
125    fn eval_screening_proxy(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
126        self.eval_cost(rho)
127    }
128
129    /// Evaluate cost + gradient + (if capable) Hessian.
130    fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError>;
131
132    /// Evaluate the outer objective at the order requested by the active plan.
133    ///
134    /// The default preserves legacy behavior by delegating value-only requests
135    /// to [`OuterObjective::eval_cost`] and derivative requests to
136    /// [`OuterObjective::eval`].
137    fn eval_with_order(
138        &mut self,
139        rho: &Array1<f64>,
140        order: OuterEvalOrder,
141    ) -> Result<OuterEval, EstimationError> {
142        match order {
143            OuterEvalOrder::Value => {
144                let cost = self.eval_cost(rho)?;
145                Ok(OuterEval::value_only(cost, rho.len(), None))
146            }
147            OuterEvalOrder::ValueAndGradient | OuterEvalOrder::ValueGradientHessian => {
148                self.eval(rho)
149            }
150        }
151    }
152
153    /// Evaluate cost + EFS step vector. Only needed when the plan selects
154    /// `Solver::Efs`. The default returns an error indicating EFS is not
155    /// supported by this objective.
156    fn eval_efs(&mut self, rho: &Array1<f64>) -> Result<EfsEval, EstimationError> {
157        Err(EstimationError::RemlOptimizationFailed(format!(
158            "EFS evaluation not implemented for this objective at rho_dim={}",
159            rho.len()
160        )))
161    }
162
163    /// Re-evaluate the terminal fixed point and provide an explicit analytic
164    /// residual for every optimized coordinate.
165    ///
166    /// This is a proof surface, not an alias for [`Self::eval_efs`]: iteration
167    /// steps may contain guarded or structurally unsupported zeros. The default
168    /// refuses certification so an EFS-capable objective must deliberately
169    /// describe complete, root-equivalent coordinate coverage before a fixed-
170    /// point result can mint a fit.
171    fn eval_fixed_point_certificate(
172        &mut self,
173        rho: &Array1<f64>,
174    ) -> Result<FixedPointCertificateEval, EstimationError> {
175        Err(EstimationError::RemlOptimizationFailed(format!(
176            "fixed-point certification not implemented for this objective at rho_dim={}",
177            rho.len()
178        )))
179    }
180
181    /// Analytic λ→∞ limit data for a rail face (#2348 Inc 5).
182    ///
183    /// `face` lists the ρ-coordinates sitting at their infinite-smoothing
184    /// bound. An objective that can form the limit EXACTLY — the fit
185    /// restricted to the railed penalties' common null space, together with
186    /// the analytic first-order form of the criterion's logdet and trace terms
187    /// there — returns it here, and the outer certificate proves the face from
188    /// it instead of probing a tail at finite λ.
189    ///
190    /// A decline is not a failure, and it is typed: `OutsideClosedForm` leaves
191    /// room for a different closed form to apply, while `FaceUnavailable` is a
192    /// statement about the face itself. Either way the caller keeps whatever
193    /// evidence it already had. The default declines for every objective that
194    /// has no analytic limit at all.
195    fn rail_face_limit(
196        &mut self,
197        rho: &Array1<f64>,
198        face: &[usize],
199    ) -> Result<RailFaceLimitOutcome, EstimationError> {
200        if face.iter().any(|&k| k >= rho.len()) {
201            return Err(EstimationError::RemlOptimizationFailed(format!(
202                "rail face {face:?} is outside the rho layout of dimension {}",
203                rho.len()
204            )));
205        }
206        Ok(RailFaceLimitOutcome::OutsideClosedForm {
207            reason: "this objective has no analytic face limit".to_string(),
208        })
209    }
210
211    /// Restore to a clean baseline for the next multi-start candidate.
212    fn reset(&mut self);
213
214    /// Whether this objective owns a terminal *coefficient* mode whose bitwise
215    /// identity fit assembly will later bind against the certified outer value.
216    ///
217    /// The certification sequence (`run.rs`) installs the terminal state twice
218    /// at `result.rho`: once via [`Self::finalize_outer_result`] (which the
219    /// mode-owning evaluator uses to install its coefficient mode) and once via
220    /// the analytic re-evaluation inside `certify_outer_optimality` (which sets
221    /// `result.final_value`). On a nonconvex profiled objective those two
222    /// evaluations can settle in *different* coefficient basins unless each is
223    /// forced to re-install from the same clean baseline through [`Self::reset`]
224    /// — otherwise they prime the inner solve off whatever warm state the
225    /// preceding diagnostic/finalize left behind, and the mode's objective and
226    /// the certified value disagree by a whole basin (measured: `9.1931e2` vs
227    /// `9.1671e2` on the cause-specific survival gate).
228    ///
229    /// That terminal reset is otherwise gated on `config.outer_inner_cap`,
230    /// which the REML/mixture objectives wire but the custom-family (and any
231    /// other terminal-mode-owning closure) objective does not — it holds its
232    /// inner cap in a different field and leaves `outer_inner_cap` `None`, so
233    /// the reset never fires and the bitwise bind can spuriously fail on a
234    /// bimodal inner solve. Returning `true` here forces the terminal reset
235    /// *independently of the cap*, so `finalize` and `certify` provably come
236    /// from one fresh evaluation at `rho_star`. It deliberately does NOT touch
237    /// the `inner_solve_converged(config.outer_inner_cap)` gate: an objective
238    /// that owns a terminal mode but does not populate the cap's convergence
239    /// atomic keeps its own stateful convergence semantics.
240    ///
241    /// The default is `false`: an objective that owns no terminal coefficient
242    /// mode (the reactive-domain fixture among them) retains the very state its
243    /// evaluation at `result.rho` depends on and must not be reset.
244    fn owns_terminal_coefficient_mode(&self) -> bool {
245        false
246    }
247
248    /// Transition an objective that actually used an approximate derivative
249    /// pilot to its exact full-data measure.
250    ///
251    /// The runner calls this once after the pilot solver returns a checkpoint.
252    /// `true` means the objective changed measure and must be optimized again
253    /// from that checkpoint before analytic certification. Exact objectives and
254    /// pilots that never installed a sample return `false`.
255    fn begin_exact_polish(&mut self) -> bool {
256        false
257    }
258
259    /// Seed the inner-solver iterate before the first eval, e.g. when the
260    /// outer-iterate cache restored a `(ρ, β)` pair from a prior run, or
261    /// when a typed reactive continuation path forwards
262    /// `OuterEval::inner_beta_hint`
263    /// from the previous step.
264    ///
265    /// Objectives make an explicit choice via the [`SeedOutcome`] return:
266    /// implementations with an inner β slot return [`SeedOutcome::Installed`]
267    /// after storing β; implementations without one return
268    /// [`SeedOutcome::NoSlot`]. Genuine seeding failures (wrong dimension
269    /// when a slot exists, etc.) are reported via `Err(EstimationError)`.
270    ///
271    /// Callers that need to distinguish "no slot" from "installed" (the
272    /// outer cache warm-start path, which logs cache provenance) branch on
273    /// the variant. Callers that don't care (the reactive continuation path,
274    /// which only proceeds cold when the hint is unusable) ignore it and only
275    /// propagate `Err`.
276    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError>;
277
278    /// Optional objective-owned hard upper domain for the outer coordinates.
279    ///
280    /// The generic optimizer intersects this vector with its configured box
281    /// before projecting seeds, constructing a solver, or opening reactive
282    /// continuation. Consequently the exact same upper endpoint is both the
283    /// solver's legal box face and the continuation path's literal rho entry.
284    /// `None` means the objective has no domain narrower than the configured
285    /// generic box. An advertised vector must have `capability().n_params`
286    /// finite entries; malformed contracts are typed runner errors.
287    fn outer_domain_upper_bound(&self) -> Result<Option<Array1<f64>>, EstimationError> {
288        Ok(None)
289    }
290
291    /// Optional objective-owned hard lower domain for the outer coordinates.
292    ///
293    /// This is intersected with the caller's configured box at the same single
294    /// runner seam as [`Self::outer_domain_upper_bound`], before any seed,
295    /// continuation waypoint, solver evaluation, or stationarity certificate can
296    /// observe an out-of-domain coordinate.
297    fn outer_domain_lower_bound(&self) -> Result<Option<Array1<f64>>, EstimationError> {
298        Ok(None)
299    }
300
301    /// Optional opt-in to the device-resident outer REML BFGS-over-ρ driver
302    /// (`crate::gpu::reml_outer::run_reml_outer_on_device`). Returns
303    /// `Some(adm)` when the objective is a REML evaluator whose
304    /// `(spec, n, p, num_rho)` admission predicate accepts the device path,
305    /// and `None` otherwise.
306    ///
307    /// The default returns `None` so non-REML objectives (line-search-only
308    /// inner bridges, screening proxies, the EFS / hybrid-EFS sub-objectives)
309    /// keep the host BFGS branch unconditionally — only the concrete
310    /// REML-state objectives override this to consult
311    /// [`crate::estimate::reml::outer_eval::outer_reml_device_admission`].
312    fn outer_device_admission(&self) -> Option<gam_gpu::policy::RemlOuterAdmission> {
313        None
314    }
315
316    /// Typed scalar continuation contract for repairing a non-finite literal
317    /// outer seed through [`crate::continuation_path::ContinuationPath`].
318    ///
319    /// This is a typed domain-entry capability, not a fallback objective. The
320    /// objective supplies both the smoother entry state and its literal target
321    /// state. `None` means this objective has no such domain homotopy. The
322    /// runner always probes the real seed first, so merely supplying a contract
323    /// performs no waypoint installation or heavy work on a finite seed.
324    fn reactive_domain_scalar_contract(
325        &self,
326    ) -> Result<Option<crate::continuation_path::ContinuationScalarContract>, EstimationError> {
327        Ok(None)
328    }
329
330    /// Install one scalar waypoint before the continuation rho spine evaluates
331    /// the objective. Objectives that return `Some` from
332    /// [`Self::reactive_domain_scalar_contract`] must override this method; the
333    /// default is a typed contract refusal, never a silent no-op.
334    fn install_reactive_domain_scalar_state(
335        &mut self,
336        state: &crate::continuation_path::ContinuationScalarState,
337    ) -> Result<(), EstimationError> {
338        Err(EstimationError::RemlOptimizationFailed(format!(
339            "objective supplied a reactive-domain scalar contract but cannot install its \
340             waypoint (temperature={}, isometry_dim={})",
341            state.assignment_temperature,
342            state.isometry_weights.len(),
343        )))
344    }
345
346    /// Snapshot the objective's complete accepted inner state before a reactive
347    /// coupled waypoint is installed. Contract-advertising objectives must make
348    /// this transactional: a failed trial is restored by
349    /// [`Self::rollback_reactive_domain_waypoint`].
350    fn begin_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
351        Err(EstimationError::RemlOptimizationFailed(
352            "objective supplied a reactive-domain scalar contract but cannot checkpoint a waypoint"
353                .to_string(),
354        ))
355    }
356
357    /// Commit the converged full inner state produced by the value evaluation
358    /// at `rho`. A coefficient-only handoff is insufficient: latent coordinates,
359    /// routing logits, decoder frames, loss, and scalar state must advance as one
360    /// accepted waypoint.
361    fn commit_reactive_domain_waypoint(
362        &mut self,
363        rho: &Array1<f64>,
364    ) -> Result<(), EstimationError> {
365        Err(EstimationError::RemlOptimizationFailed(format!(
366            "objective supplied a reactive-domain scalar contract but cannot commit a waypoint \
367             (rho_dim={})",
368            rho.len(),
369        )))
370    }
371
372    /// Restore the full accepted state saved by
373    /// [`Self::begin_reactive_domain_waypoint`] after an errored or non-finite
374    /// trial.
375    fn rollback_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
376        Err(EstimationError::RemlOptimizationFailed(
377            "objective supplied a reactive-domain scalar contract but cannot roll back a waypoint"
378                .to_string(),
379        ))
380    }
381
382    /// Run the objective's certified curvature-homotopy entry leg, if it has
383    /// one, leaving the inner state warm at the real (`η = 1`) objective.
384    ///
385    /// An objective with a *certified anchor* — a point known by construction to
386    /// be the global optimum of a relaxed problem — can replace the blind
387    /// multi-seed multistart with a single predictor-corrector walk from that
388    /// anchor to the true objective (#1007). The SAE-manifold objective
389    /// overrides this: its `η = 0` base-topology relaxation is convex, and a
390    /// genuine low-rank (Eckart-Young / SVD) residual ceiling is certified by
391    /// `linear_span_anchor` — the `η = 0` endpoint is NOT a linear/affine model
392    /// (for curved bases its base columns still embed curvature); "Eckart-Young"
393    /// names the rank ceiling, not the chart. The walk in `η` tracks the unique
394    /// optimal branch to `η = 1`. The walk monitors the
395    /// arrow-factor min-pivot and halves the `η` step when it shrinks; a pivot
396    /// collapse below tolerance is a DETECTED bifurcation (recorded on the fit
397    /// payload, never silent), at which point the objective falls back to the
398    /// documented multi-seed cascade.
399    ///
400    /// Returns:
401    ///   * `None` — no certified anchor; use the standard seed cascade
402    ///     (the default for every other objective).
403    ///   * `Some(Ok(true))` — the walk arrived; the inner state is warm at the
404    ///     certified `η = 1` solution and the seed cascade is bypassed.
405    ///   * `Some(Ok(false))` — the anchor degenerated or the walk detected a
406    ///     bifurcation; fall back to the multi-seed cascade (the report is
407    ///     recorded on the objective for the fit payload).
408    ///   * `Some(Err(_))` — a hard failure constructing the anchor.
409    fn curvature_homotopy_entry(
410        &mut self,
411        rho: &Array1<f64>,
412    ) -> Option<Result<bool, EstimationError>> {
413        // Default: no certified anchor — but a non-finite seed is reported
414        // here rather than silently handed to the seed cascade, mirroring the
415        // hard-failure contract of the overriding implementations.
416        if let Some(idx) = rho.iter().position(|v| !v.is_finite()) {
417            return Some(Err(EstimationError::RemlOptimizationFailed(format!(
418                "curvature-homotopy entry received non-finite rho[{idx}]"
419            ))));
420        }
421        None
422    }
423
424    /// Let an objective declare that a seed is already a terminal outer result.
425    /// Used for objectives with a certified high-quality construction seed where
426    /// the generic rho optimizer can only degrade the fitted state.
427    fn accept_seed_without_outer_iterations(
428        &mut self,
429        rho: &Array1<f64>,
430    ) -> Result<Option<f64>, EstimationError> {
431        if rho.is_empty() {
432            return Ok(None);
433        }
434        Ok(None)
435    }
436
437    /// Optional analytic evaluation order that must own the final installed
438    /// objective state, independently of the solver plan that found `rho`.
439    ///
440    /// The default follows the solver (`EFS` finalizes through `eval_efs`,
441    /// BFGS through first order, ARC through second order). Stateful profiled
442    /// objectives may override this when only one evaluator produces the
443    /// ownership payload consumed by fit assembly.
444    fn terminal_eval_order(&self) -> Option<OuterEvalOrder> {
445        None
446    }
447
448    /// Re-install the selected outer result into the mutable objective before
449    /// callers consume objective-owned fitted state. Optimizers may evaluate
450    /// rejected trial points after the best point was found; without this final
451    /// synchronization, stateful objectives can report the last trial fit rather
452    /// than the returned `OuterResult::rho`.
453    fn finalize_outer_result(
454        &mut self,
455        rho: &Array1<f64>,
456        plan: &OuterPlan,
457    ) -> Result<(), EstimationError> {
458        log::debug!(
459            "[OUTER] finalize: re-installing best rho into the objective (solver {:?})",
460            plan.solver
461        );
462        let order = self.terminal_eval_order().or(match plan.solver {
463            Solver::Efs | Solver::HybridEfs => None,
464            Solver::Bfgs => Some(OuterEvalOrder::ValueAndGradient),
465            Solver::Arc => Some(OuterEvalOrder::ValueGradientHessian),
466        });
467        match order {
468            Some(order) => self.eval_with_order(rho, order).map(|_| ()),
469            None => self.eval_efs(rho).map(|_| ()),
470        }
471    }
472}
473
474// ─── Persistent warm-start checkpoint plumbing ────────────────────────
475//
476// `CheckpointingObjective` wraps any `OuterObjective` to write a copy of
477// `(rho, cost, eval_id)` to disk on each finite evaluation. The on-disk
478// [`gam_runtime::warm_start::Session`] rate-limits writes (≥2 s gap unless this iterate
479// strictly improves on the best-so-far) so a tight inner loop never thrashes
480// the filesystem. The same checkpoint is also broadcast to optional mirror
481// sessions, which lets interrupted exact-key runs seed later related fits via
482// their prefix key instead of waiting for a final converged write.
483
484#[derive(serde::Serialize, serde::Deserialize)]
485pub(crate) struct IteratePayload {
486    /// Bump on incompatible payload changes; decode rejects mismatches.
487    schema: u32,
488    pub(crate) rho: Vec<f64>,
489    /// Inner-solver iterate (PIRLS β) captured alongside ρ. The (ρ, β)
490    /// pair lives on the implicit-function manifold β = β*(ρ); restoring
491    /// ρ alone forces the next inner solve to reconstruct β from scratch.
492    /// For saturated ρ (|ρ_i| near `rho_bound`) the inner Hessian
493    /// `X'WX + Σ λ_i S_i` has condition number `≈ e^{2·rho_bound}` — Newton
494    /// degrades to O(1/k) descent and the cycle budget exhausts before
495    /// KKT. Caching β lets the resume start in Newton's quadratic basin
496    /// regardless of where ρ lives. Empty when the family did not surface
497    /// an inner-β hint at write time (still useful as a ρ-only seed).
498    #[serde(default)]
499    pub(crate) beta: Vec<f64>,
500    /// Converged exact outer curvature `H(θ̂)` (full θ×θ, row-major flatten),
501    /// captured alongside the (ρ, β) iterate. A gradient-based BFGS solve does
502    /// not surface its accumulated inverse-Hessian, so the next
503    /// structurally-matching fit (e.g. the next LOSO fold) otherwise restarts
504    /// BFGS from an unscaled identity metric and rediscovers curvature through
505    /// line-search bracketing — multiple full inner-solve value probes per
506    /// accepted outer step. Persisting the converged curvature lets the resume
507    /// seed `InitialMetric::DenseInverseHessian(H⁻¹)` for a quasi-Newton first
508    /// step. Empty when no exact outer Hessian was available at write time
509    /// (still a valid ρ/β seed). `hessian_dim²` must equal `hessian.len()`.
510    #[serde(default)]
511    pub(crate) hessian: Vec<f64>,
512    /// Side length of the square `hessian` matrix (`hessian.len() == dim²`).
513    /// Zero when no Hessian was persisted.
514    #[serde(default)]
515    pub(crate) hessian_dim: usize,
516    pub(crate) cost: f64,
517    eval_id: u64,
518}
519
520/// Entries with a different schema id are rejected by `decode_iterate`
521/// so incompatible on-disk payloads fall through to cold start instead
522/// of seeding the inner solve with a malformed iterate.
523/// Schema 3 invalidates every payload written before outer-Hessian provenance
524/// was tied to the objective's declared analytic capability. In particular,
525/// schema-2 SAE checkpoints may contain the now-deleted finite-difference
526/// curvature and must never influence a resumed quasi-Newton metric (#2253).
527pub(crate) const ITERATE_PAYLOAD_SCHEMA: u32 = 3;
528
529pub(crate) fn encode_iterate(
530    rho: &Array1<f64>,
531    beta: Option<&Array1<f64>>,
532    hessian: Option<&Array2<f64>>,
533    cost: f64,
534    eval_id: u64,
535) -> Option<Vec<u8>> {
536    // Persist the converged outer curvature only when it is square and finite;
537    // a non-finite or non-square Hessian is dropped (the resume falls back to a
538    // ρ/β-only seed) so a malformed curvature can never corrupt a warm start.
539    let (hessian_flat, hessian_dim) = match hessian {
540        Some(h) if h.nrows() == h.ncols() && h.iter().all(|v| v.is_finite()) => {
541            (h.iter().copied().collect::<Vec<f64>>(), h.nrows())
542        }
543        _ => (Vec::new(), 0),
544    };
545    let p = IteratePayload {
546        schema: ITERATE_PAYLOAD_SCHEMA,
547        rho: rho.to_vec(),
548        beta: beta.map(|b| b.to_vec()).unwrap_or_default(),
549        hessian: hessian_flat,
550        hessian_dim,
551        cost,
552        eval_id,
553    };
554    serde_json::to_vec(&p).ok()
555}
556
557pub(crate) fn decode_iterate(bytes: &[u8], expected_rho_dim: usize) -> Option<IteratePayload> {
558    let mut p: IteratePayload = serde_json::from_slice(bytes).ok()?;
559    if p.schema != ITERATE_PAYLOAD_SCHEMA {
560        return None;
561    }
562    if p.rho.len() != expected_rho_dim {
563        return None;
564    }
565    if !p.rho.iter().all(|x| x.is_finite()) || !p.cost.is_finite() {
566        return None;
567    }
568    if !p.beta.iter().all(|x| x.is_finite()) {
569        return None;
570    }
571    // A persisted Hessian must be square (`dim²` entries) and finite to be
572    // usable as a warm-start metric; an inconsistent or non-finite curvature is
573    // scrubbed to "no Hessian" rather than rejecting the whole iterate, so the
574    // ρ/β seed still warms the resume.
575    if p.hessian_dim.saturating_mul(p.hessian_dim) != p.hessian.len()
576        || !p.hessian.iter().all(|x| x.is_finite())
577    {
578        p.hessian = Vec::new();
579        p.hessian_dim = 0;
580    }
581    Some(p)
582}
583
584/// Outcome of inspecting a cache entry as a seed for the outer optimizer.
585///
586/// The classifier rejects only entries that fail structural validity
587/// (wrong dimension, non-finite payload). It does NOT reshape ρ based on
588/// saturation: every finite, well-shaped entry is honored as the next
589/// run's seed.
590///
591/// Previously this enum carried `saturated_coords` / `clamped_to` /
592/// "all-coords-saturated-poisoned-entry" branches that pulled boundary
593/// ρ inward or discarded fully-saturated entries. Those were read-side
594/// band-aids over the real bug: the warm-start contract stored ρ but
595/// not β, so resuming at boundary ρ forced PIRLS to recompute β from
596/// cold-start against a Hessian with condition number `≈ e^{2·rho_bound}`,
597/// and Newton degraded to O(1/k) descent that exhausted the cycle budget.
598///
599/// The contract is now `(ρ, β)`: the current iterate payload carries
600/// both, and [`CheckpointingObjective`] refuses to persist a divergent
601/// inner state (non-finite cost or β). Boundary ρ — when written under
602/// the new invariant — is a *legitimate* finding (the smoothness wants
603/// to be near-null), and the cached β puts the next inner solve at the
604/// previously converged iterate where the gradient is already at zero.
605/// No clamp or shape-based discard is needed.
606#[derive(Debug)]
607pub(crate) enum CacheSeedDecision {
608    ExactFinal {
609        rho: Array1<f64>,
610        /// Optional inner β captured at the converged ρ. Empty when the
611        /// payload didn't carry one (legacy ρ-only writes or families
612        /// that don't surface β).
613        beta: Vec<f64>,
614        iterations: usize,
615        prior_obj_display: f64,
616    },
617    Seed {
618        rho: Array1<f64>,
619        /// Optional inner β to prime the next run's inner solver via
620        /// [`OuterObjective::seed_inner_state`]. When non-empty, the
621        /// dispatcher injects β before the first eval so the inner
622        /// PIRLS opens at zero-gradient regardless of where ρ sits in
623        /// the box.
624        beta: Vec<f64>,
625        /// Optional converged outer Hessian `H(θ̂)` from the prior fit, as a
626        /// `(dim, row-major flatten)` pair. `None` when the payload carried no
627        /// curvature (legacy ρ/β-only writes). Seeds the BFGS iter-0 metric on
628        /// the resume so the first outer step is quasi-Newton.
629        hessian: Option<(usize, Vec<f64>)>,
630        prior_obj_display: f64,
631        iteration: u64,
632    },
633    Discard {
634        reason: &'static str,
635        prior_obj_display: f64,
636        all_rho_finite: Option<bool>,
637    },
638}
639
640pub(crate) fn classify_cache_entry_for_outer(
641    loaded: &gam_runtime::warm_start::LoadedEntry,
642    expected_rho_dim: usize,
643) -> CacheSeedDecision {
644    let entry = &loaded.entry;
645    let Some(payload) = decode_iterate(&entry.payload, expected_rho_dim) else {
646        return CacheSeedDecision::Discard {
647            reason: "payload-shape-mismatch",
648            prior_obj_display: entry.objective.unwrap_or(f64::NAN),
649            all_rho_finite: None,
650        };
651    };
652    let cached_rho = Array1::from_vec(payload.rho);
653    let prior_obj_display = entry.objective.unwrap_or(f64::NAN);
654    if matches!(entry.objective, Some(v) if !v.is_finite()) {
655        return CacheSeedDecision::Discard {
656            reason: "non-finite-payload",
657            prior_obj_display,
658            all_rho_finite: Some(cached_rho.iter().all(|v| v.is_finite())),
659        };
660    }
661    if !cached_rho.iter().all(|v| v.is_finite()) {
662        return CacheSeedDecision::Discard {
663            reason: "non-finite-payload",
664            prior_obj_display,
665            all_rho_finite: Some(false),
666        };
667    }
668    if loaded.source == LoadSource::Exact && entry.kind == gam_runtime::warm_start::EntryKind::Final
669    {
670        return CacheSeedDecision::ExactFinal {
671            rho: cached_rho,
672            beta: payload.beta,
673            iterations: entry
674                .iteration
675                .unwrap_or(payload.eval_id)
676                .min(usize::MAX as u64) as usize,
677            prior_obj_display,
678        };
679    }
680    let hessian = if payload.hessian_dim > 0
681        && payload.hessian.len() == payload.hessian_dim * payload.hessian_dim
682    {
683        Some((payload.hessian_dim, payload.hessian))
684    } else {
685        None
686    };
687    CacheSeedDecision::Seed {
688        rho: cached_rho,
689        beta: payload.beta,
690        hessian,
691        prior_obj_display,
692        iteration: entry.iteration.unwrap_or(payload.eval_id),
693    }
694}
695
696pub fn cache_entry_would_help_outer(
697    loaded: &gam_runtime::warm_start::LoadedEntry,
698    expected_rho_dim: usize,
699) -> bool {
700    matches!(
701        classify_cache_entry_for_outer(loaded, expected_rho_dim),
702        CacheSeedDecision::ExactFinal { .. } | CacheSeedDecision::Seed { .. }
703    )
704}
705
706pub(crate) struct CheckpointingObjective<'a> {
707    inner: &'a mut dyn OuterObjective,
708    session: Arc<CacheSession>,
709    mirror_sessions: Vec<Arc<CacheSession>>,
710    eval_counter: AtomicU64,
711    /// Most-recent inner β surfaced via [`OuterEval::inner_beta_hint`]. The
712    /// finalize path reads this so the `kind: Final` write encodes the
713    /// (ρ, β) pair that the BFGS optimum was actually fitted at — without
714    /// this the finalize would clobber per-eval checkpoint β state with a
715    /// ρ-only payload, reintroducing the cold-β resume failure.
716    last_inner_beta: std::sync::Mutex<Option<Array1<f64>>>,
717    /// True only while the typed reactive-domain path evaluates an
718    /// initialization waypoint. Those waypoints are transactional means of
719    /// reaching the literal requested model, not candidate outer iterates, so
720    /// they must never become persistent restart seeds.
721    reactive_waypoint_active: AtomicBool,
722}
723
724impl<'a> CheckpointingObjective<'a> {
725    pub(crate) fn new(
726        inner: &'a mut dyn OuterObjective,
727        session: Arc<CacheSession>,
728        mirror_sessions: Vec<Arc<CacheSession>>,
729    ) -> Self {
730        Self {
731            inner,
732            session,
733            mirror_sessions,
734            eval_counter: AtomicU64::new(0),
735            last_inner_beta: std::sync::Mutex::new(None),
736            reactive_waypoint_active: AtomicBool::new(false),
737        }
738    }
739
740    pub(crate) fn last_inner_beta(&self) -> Option<Array1<f64>> {
741        self.last_inner_beta.lock().ok().and_then(|g| g.clone())
742    }
743
744    fn note(&self, rho: &Array1<f64>, beta: Option<&Array1<f64>>, cost: f64) {
745        if self.reactive_waypoint_active.load(Ordering::Relaxed) {
746            return;
747        }
748        if !cost.is_finite() {
749            return;
750        }
751        // If β is provided, require it to be finite; non-finite β is a
752        // divergent inner state — persisting it would re-poison the cache.
753        if let Some(b) = beta {
754            if !b.iter().all(|v| v.is_finite()) {
755                return;
756            }
757            if let Ok(mut guard) = self.last_inner_beta.lock() {
758                *guard = Some(b.clone());
759            }
760        }
761        let i = self.eval_counter.fetch_add(1, Ordering::Relaxed);
762        // Per-eval checkpoints carry no converged outer Hessian (curvature is
763        // only meaningful at the final optimum); the finalize write is where the
764        // converged `H(θ̂)` is persisted for cross-fit warm starts.
765        if let Some(bytes) = encode_iterate(rho, beta, None, cost, i) {
766            self.session.checkpoint(&bytes, Some(cost), Some(i));
767            for mirror in &self.mirror_sessions {
768                mirror.checkpoint(&bytes, Some(cost), Some(i));
769            }
770        }
771    }
772}
773
774impl<'a> OuterObjective for CheckpointingObjective<'a> {
775    fn capability(&self) -> OuterCapability {
776        self.inner.capability()
777    }
778
779    fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
780        let v = self.inner.eval_cost(rho)?;
781        // `eval_cost` carries no inner-β handle — persist ρ-only.
782        self.note(rho, None, v);
783        Ok(v)
784    }
785
786    fn eval_screening_proxy(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
787        // Screening proxies run at sub-converged β̂ and aren't a meaningful
788        // best-so-far signal; forward without persisting.
789        self.inner.eval_screening_proxy(rho)
790    }
791
792    fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
793        let r = self.inner.eval(rho)?;
794        self.note(rho, r.inner_beta_hint.as_ref(), r.cost);
795        Ok(r)
796    }
797
798    fn eval_with_order(
799        &mut self,
800        rho: &Array1<f64>,
801        order: OuterEvalOrder,
802    ) -> Result<OuterEval, EstimationError> {
803        let r = self.inner.eval_with_order(rho, order)?;
804        self.note(rho, r.inner_beta_hint.as_ref(), r.cost);
805        Ok(r)
806    }
807
808    fn eval_efs(&mut self, rho: &Array1<f64>) -> Result<EfsEval, EstimationError> {
809        let r = self.inner.eval_efs(rho)?;
810        // EfsEval has no inner-β hint surface yet — persist ρ-only.
811        self.note(rho, None, r.cost);
812        Ok(r)
813    }
814
815    fn eval_fixed_point_certificate(
816        &mut self,
817        rho: &Array1<f64>,
818    ) -> Result<FixedPointCertificateEval, EstimationError> {
819        let r = self.inner.eval_fixed_point_certificate(rho)?;
820        self.note(rho, None, r.cost);
821        Ok(r)
822    }
823
824    fn rail_face_limit(
825        &mut self,
826        rho: &Array1<f64>,
827        face: &[usize],
828    ) -> Result<RailFaceLimitOutcome, EstimationError> {
829        self.inner.rail_face_limit(rho, face)
830    }
831
832    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
833        // Forward to the wrapped objective, then prime our last-inner-beta
834        // cache so a subsequent finalize-write encodes the seeded β if no
835        // eval surfaces a fresher β first. Only prime on actual install —
836        // `NoSlot` means the inner solver will not see β, so the cache
837        // entry would be a lie.
838        let result = self.inner.seed_inner_state(beta);
839        if matches!(result, Ok(SeedOutcome::Installed))
840            && beta.iter().all(|v| v.is_finite())
841            && let Ok(mut guard) = self.last_inner_beta.lock()
842        {
843            *guard = Some(beta.clone());
844        }
845        result
846    }
847
848    fn terminal_eval_order(&self) -> Option<OuterEvalOrder> {
849        self.inner.terminal_eval_order()
850    }
851
852    fn owns_terminal_coefficient_mode(&self) -> bool {
853        // Forward the wrapped objective's ownership: the terminal reset must
854        // still fire for a cap-less mode owner (e.g. a custom family) when its
855        // fit routes through a cache session and is wrapped here (#2334).
856        self.inner.owns_terminal_coefficient_mode()
857    }
858
859    fn reactive_domain_scalar_contract(
860        &self,
861    ) -> Result<Option<crate::continuation_path::ContinuationScalarContract>, EstimationError> {
862        self.inner.reactive_domain_scalar_contract()
863    }
864
865    fn install_reactive_domain_scalar_state(
866        &mut self,
867        state: &crate::continuation_path::ContinuationScalarState,
868    ) -> Result<(), EstimationError> {
869        self.inner.install_reactive_domain_scalar_state(state)
870    }
871
872    fn begin_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
873        self.inner.begin_reactive_domain_waypoint()?;
874        self.reactive_waypoint_active
875            .store(true, Ordering::Relaxed);
876        Ok(())
877    }
878
879    fn commit_reactive_domain_waypoint(
880        &mut self,
881        rho: &Array1<f64>,
882    ) -> Result<(), EstimationError> {
883        let result = self.inner.commit_reactive_domain_waypoint(rho);
884        self.reactive_waypoint_active
885            .store(false, Ordering::Relaxed);
886        result
887    }
888
889    fn rollback_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
890        let result = self.inner.rollback_reactive_domain_waypoint();
891        self.reactive_waypoint_active
892            .store(false, Ordering::Relaxed);
893        result
894    }
895
896    fn reset(&mut self) {
897        self.reactive_waypoint_active
898            .store(false, Ordering::Relaxed);
899        self.inner.reset();
900    }
901
902    fn begin_exact_polish(&mut self) -> bool {
903        self.inner.begin_exact_polish()
904    }
905}
906
907/// Closure-based adapter for [`OuterObjective`].
908///
909/// This allows any call site to construct an `OuterObjective` from closures
910/// without needing to define a wrapper struct or modify the state type.
911/// Each call site wraps its existing methods into closures and passes them here.
912pub struct ClosureObjective<
913    S,
914    Fc,
915    Fe,
916    Fr = fn(&mut S),
917    Fefs = fn(&mut S, &Array1<f64>) -> Result<EfsEval, EstimationError>,
918    Feo = fn(&mut S, &Array1<f64>, OuterEvalOrder) -> Result<OuterEval, EstimationError>,
919    Fsp = fn(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
920    Fseed = fn(&mut S, &Array1<f64>) -> Result<SeedOutcome, EstimationError>,
921> {
922    pub state: S,
923    pub(crate) cap: OuterCapability,
924    pub(crate) cost_fn: Fc,
925    pub(crate) eval_fn: Fe,
926    /// Optional order-aware eval closure. When `None`, `eval_with_order()`
927    /// dispatches value-only work to `cost_fn` and derivative-bearing work to
928    /// `eval_fn`, matching the [`OuterObjective`] default contract.
929    pub(crate) eval_order_fn: Option<Feo>,
930    /// Optional reset closure. When `None`, `reset()` is a no-op.
931    pub(crate) reset_fn: Option<Fr>,
932    /// Optional EFS evaluation closure. When `None`, the default
933    /// `OuterObjective::eval_efs` returns an error.
934    pub(crate) efs_fn: Option<Fefs>,
935    pub(crate) fixed_point_certificate_fn: Option<
936        Box<dyn FnMut(&mut S, &Array1<f64>) -> Result<FixedPointCertificateEval, EstimationError>>,
937    >,
938    /// Optional single-shot transition from an approximate derivative pilot to
939    /// the exact objective measure.
940    pub(crate) exact_polish_fn: Option<Box<dyn FnMut(&mut S) -> bool>>,
941    /// Optional analytic λ→∞ rail-face limit hook (#2348 Inc 5). Installed by
942    /// objectives whose criterion has an exact closed-form limit at an
943    /// infinite-smoothing face; `None` means the outer certificate falls back
944    /// to measuring the tail.
945    pub(crate) rail_face_limit_fn: Option<
946        Box<
947            dyn FnMut(
948                &mut S,
949                &Array1<f64>,
950                &[usize],
951            ) -> Result<RailFaceLimitOutcome, EstimationError>,
952        >,
953    >,
954    /// Optional seed-screening ranking proxy closure. When `None`,
955    /// `eval_screening_proxy()` falls back to `eval_cost()` (the trait
956    /// default), preserving legacy behavior for non-REML objectives.
957    pub(crate) screening_proxy_fn: Option<Fsp>,
958    /// Optional inner-state seeding closure. Objectives with PIRLS / Newton
959    /// inner state install cached β here before the first outer eval.
960    pub(crate) seed_fn: Option<Fseed>,
961    /// Analytic evaluator that must install the terminal owned state even when
962    /// the selected optimization plan itself used EFS.
963    pub(crate) terminal_eval_order: Option<OuterEvalOrder>,
964}
965
966impl<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed> OuterObjective
967    for ClosureObjective<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed>
968where
969    Fc: FnMut(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
970    Fe: FnMut(&mut S, &Array1<f64>) -> Result<OuterEval, EstimationError>,
971    Fr: FnMut(&mut S),
972    Fefs: FnMut(&mut S, &Array1<f64>) -> Result<EfsEval, EstimationError>,
973    Feo: FnMut(&mut S, &Array1<f64>, OuterEvalOrder) -> Result<OuterEval, EstimationError>,
974    Fsp: FnMut(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
975    Fseed: FnMut(&mut S, &Array1<f64>) -> Result<SeedOutcome, EstimationError>,
976{
977    fn capability(&self) -> OuterCapability {
978        self.cap.clone()
979    }
980
981    fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
982        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
983        (self.cost_fn)(&mut self.state, rho)
984    }
985
986    fn eval_screening_proxy(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
987        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
988        match self.screening_proxy_fn.as_mut() {
989            Some(f) => f(&mut self.state, rho),
990            None => (self.cost_fn)(&mut self.state, rho),
991        }
992    }
993
994    fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
995        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
996        (self.eval_fn)(&mut self.state, rho)
997    }
998
999    fn eval_with_order(
1000        &mut self,
1001        rho: &Array1<f64>,
1002        order: OuterEvalOrder,
1003    ) -> Result<OuterEval, EstimationError> {
1004        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
1005        match self.eval_order_fn.as_mut() {
1006            Some(f) => f(&mut self.state, rho, order),
1007            None => match order {
1008                OuterEvalOrder::Value => {
1009                    let cost = (self.cost_fn)(&mut self.state, rho)?;
1010                    Ok(OuterEval::value_only(cost, rho.len(), None))
1011                }
1012                OuterEvalOrder::ValueAndGradient | OuterEvalOrder::ValueGradientHessian => {
1013                    (self.eval_fn)(&mut self.state, rho)
1014                }
1015            },
1016        }
1017    }
1018
1019    fn eval_efs(&mut self, rho: &Array1<f64>) -> Result<EfsEval, EstimationError> {
1020        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
1021        match self.efs_fn.as_mut() {
1022            Some(f) => f(&mut self.state, rho),
1023            None => Err(EstimationError::RemlOptimizationFailed(
1024                "EFS evaluation not implemented for this objective".to_string(),
1025            )),
1026        }
1027    }
1028
1029    fn eval_fixed_point_certificate(
1030        &mut self,
1031        rho: &Array1<f64>,
1032    ) -> Result<FixedPointCertificateEval, EstimationError> {
1033        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
1034        match self.fixed_point_certificate_fn.as_mut() {
1035            Some(f) => f(&mut self.state, rho),
1036            None => Err(EstimationError::RemlOptimizationFailed(
1037                "fixed-point certification not implemented for this closure objective".to_string(),
1038            )),
1039        }
1040    }
1041
1042    fn rail_face_limit(
1043        &mut self,
1044        rho: &Array1<f64>,
1045        face: &[usize],
1046    ) -> Result<RailFaceLimitOutcome, EstimationError> {
1047        if face.iter().any(|&k| k >= rho.len()) {
1048            return Err(EstimationError::RemlOptimizationFailed(format!(
1049                "rail face {face:?} is outside the rho layout of dimension {}",
1050                rho.len()
1051            )));
1052        }
1053        match self.rail_face_limit_fn.as_mut() {
1054            Some(f) => f(&mut self.state, rho, face),
1055            None => Ok(RailFaceLimitOutcome::OutsideClosedForm {
1056                reason: "this objective does not implement an analytic face limit".to_string(),
1057            }),
1058        }
1059    }
1060
1061    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
1062        // Empty β: by convention, "no warm-start available" — treat as a
1063        // no-op install. Distinct from `NoSlot` because the objective may
1064        // very well have a slot; the caller just didn't supply a β to fill
1065        // it. Reporting `Installed` is correct: the slot's pre-existing
1066        // state (cold default) is the post-seed state.
1067        if beta.is_empty() {
1068            return Ok(SeedOutcome::Installed);
1069        }
1070        match self.seed_fn.as_mut() {
1071            Some(f) => f(&mut self.state, beta),
1072            // No hook installed — the objective owns no inner-β slot.
1073            // The caller decides whether this is a loud cache-provenance
1074            // event or a silent continuation-walk degradation.
1075            None => Ok(SeedOutcome::NoSlot),
1076        }
1077    }
1078
1079    fn terminal_eval_order(&self) -> Option<OuterEvalOrder> {
1080        self.terminal_eval_order
1081    }
1082
1083    fn reset(&mut self) {
1084        if let Some(f) = self.reset_fn.as_mut() {
1085            f(&mut self.state);
1086        }
1087    }
1088
1089    fn owns_terminal_coefficient_mode(&self) -> bool {
1090        // A forced terminal eval order is set *precisely* to install this
1091        // objective's owned coefficient mode through one analytic evaluator at
1092        // `rho_star` (see `terminal_eval_order`'s field doc and
1093        // `with_terminal_eval_order`). So `terminal_eval_order.is_some()` is the
1094        // existing, single-source-of-truth marker that this closure objective
1095        // owns a terminal coefficient mode — no separate flag to keep in sync.
1096        // Only the custom-family builder sets it; every other closure objective
1097        // (REML search proxies, reactive fixtures) leaves it `None` and keeps
1098        // the default `false`.
1099        self.terminal_eval_order.is_some()
1100    }
1101
1102    fn begin_exact_polish(&mut self) -> bool {
1103        self.exact_polish_fn
1104            .as_mut()
1105            .is_some_and(|transition| transition(&mut self.state))
1106    }
1107}
1108
1109impl<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed> ClosureObjective<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed> {
1110    pub fn with_exact_polish<Fpolish>(mut self, transition: Fpolish) -> Self
1111    where
1112        Fpolish: FnMut(&mut S) -> bool + 'static,
1113    {
1114        self.exact_polish_fn = Some(Box::new(transition));
1115        self
1116    }
1117
1118    /// Force final state installation through one analytic evaluator order.
1119    /// Search-time solver selection remains unchanged.
1120    pub fn with_terminal_eval_order(mut self, order: OuterEvalOrder) -> Self {
1121        self.terminal_eval_order = Some(order);
1122        self
1123    }
1124
1125    /// Install the analytic λ→∞ rail-face limit hook (#2348 Inc 5).
1126    pub fn with_rail_face_limit<Fface>(mut self, limit: Fface) -> Self
1127    where
1128        Fface: FnMut(
1129                &mut S,
1130                &Array1<f64>,
1131                &[usize],
1132            ) -> Result<RailFaceLimitOutcome, EstimationError>
1133            + 'static,
1134    {
1135        self.rail_face_limit_fn = Some(Box::new(limit));
1136        self
1137    }
1138}
1139
1140impl<S, Fc, Fe, Fr, Fefs, Feo, Fsp> ClosureObjective<S, Fc, Fe, Fr, Fefs, Feo, Fsp>
1141where
1142    Fc: FnMut(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
1143    Fe: FnMut(&mut S, &Array1<f64>) -> Result<OuterEval, EstimationError>,
1144    Fr: FnMut(&mut S),
1145    Fefs: FnMut(&mut S, &Array1<f64>) -> Result<EfsEval, EstimationError>,
1146    Feo: FnMut(&mut S, &Array1<f64>, OuterEvalOrder) -> Result<OuterEval, EstimationError>,
1147    Fsp: FnMut(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
1148{
1149    pub fn with_fixed_point_certificate<Fcert>(mut self, certificate_fn: Fcert) -> Self
1150    where
1151        Fcert: FnMut(&mut S, &Array1<f64>) -> Result<FixedPointCertificateEval, EstimationError>
1152            + 'static,
1153    {
1154        self.fixed_point_certificate_fn = Some(Box::new(certificate_fn));
1155        self
1156    }
1157
1158    pub fn with_seed_inner_state<Fseed>(
1159        self,
1160        seed_fn: Fseed,
1161    ) -> ClosureObjective<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed>
1162    where
1163        Fseed: FnMut(&mut S, &Array1<f64>) -> Result<SeedOutcome, EstimationError>,
1164    {
1165        ClosureObjective {
1166            state: self.state,
1167            cap: self.cap,
1168            cost_fn: self.cost_fn,
1169            eval_fn: self.eval_fn,
1170            eval_order_fn: self.eval_order_fn,
1171            reset_fn: self.reset_fn,
1172            efs_fn: self.efs_fn,
1173            fixed_point_certificate_fn: self.fixed_point_certificate_fn,
1174            exact_polish_fn: self.exact_polish_fn,
1175            rail_face_limit_fn: self.rail_face_limit_fn,
1176            screening_proxy_fn: self.screening_proxy_fn,
1177            seed_fn: Some(seed_fn),
1178            terminal_eval_order: self.terminal_eval_order,
1179        }
1180    }
1181}
1182
1183/// Distinctive signature of a custom-family inner solve that did not reach its
1184/// KKT fixed point, emitted by `psi_hyper` when it refuses to expose profile
1185/// objective derivatives at a non-stationary β̂ (crates/gam-custom-family/src/
1186/// psi_hyper.rs). The analytic outer gradient/Hessian require the inner KKT
1187/// equation `F_β(β, θ) = 0`; when the inner solve stalls at a particular ρ that
1188/// equation is unmet, so the trial is INFEASIBLE **at that ρ** — not a
1189/// structural defect of the problem.
1190pub(crate) const INNER_DERIVATIVE_KKT_REFUSAL_MARKER: &str =
1191    "refusing to expose profile objective derivatives";
1192
1193pub(crate) fn into_objective_error(context: &str, err: EstimationError) -> ObjectiveEvalError {
1194    let message = format!("{context}: {err}");
1195    // #2358: a non-stationary custom-family inner solve at THIS ρ is a
1196    // RECOVERABLE infeasibility (cost = ∞), not a fatal failure of the whole
1197    // outer evaluation. Routing it through `Recoverable` lets the outer
1198    // optimizer treat the trial as `OuterEval::infeasible` and BACK OFF to a
1199    // feasible optimum (interior line-search / gradient path) or reject an
1200    // infeasible seed and try the next one (seed-screening path) — the same
1201    // `OuterEval::infeasible` mechanism the value-probe path already relies on.
1202    // Previously EVERY objective error (including this per-ρ inner
1203    // non-convergence) was classified `Fatal`: a single non-convergent interior
1204    // ρ then aborted the entire fit even though the optimizer already held a
1205    // feasible optimum to fall back to (the location-scale gagurine `tp` fit).
1206    // Any ρ where the inner solve does reach stationarity is unaffected — it
1207    // never carries this marker.
1208    //
1209    // This is necessary but not always sufficient: a fit whose EVERY seed is
1210    // inner-infeasible (e.g. the wiggle two-block reference-flow, whose joint
1211    // Newton trust region collapses on the coupled mean/log-σ/wiggle blocks)
1212    // still fails, now with an honest "no candidate seeds passed validation"
1213    // instead of a fatal abort. Repairing that inner collapse is separate.
1214    if message.contains(INNER_DERIVATIVE_KKT_REFUSAL_MARKER) {
1215        ObjectiveEvalError::recoverable(message)
1216    } else {
1217        ObjectiveEvalError::fatal(message)
1218    }
1219}
1220
1221pub(crate) fn finite_cost_or_error(context: &str, cost: f64) -> Result<f64, ObjectiveEvalError> {
1222    if cost.is_finite() {
1223        Ok(cost)
1224    } else {
1225        Err(ObjectiveEvalError::recoverable(format!(
1226            "{context}: objective returned a non-finite cost"
1227        )))
1228    }
1229}
1230
1231/// Shared first-order validation: gradient length, finite cost, finite gradient.
1232///
1233/// Extracted so the cost+gradient checks live in exactly one place — both the
1234/// full (`finite_outer_eval_or_error`) and first-order
1235/// (`finite_outer_first_order_eval_or_error`) validators delegate here, keeping
1236/// their error messages and check order bit-for-bit identical.
1237fn validate_outer_first_order(
1238    context: &str,
1239    layout: OuterThetaLayout,
1240    eval: &OuterEval,
1241) -> Result<(), ObjectiveEvalError> {
1242    layout.validate_gradient_len(&eval.gradient, context)?;
1243    if !eval.cost.is_finite() {
1244        return Err(ObjectiveEvalError::recoverable(format!(
1245            "{context}: objective returned a non-finite cost"
1246        )));
1247    }
1248    if !eval.gradient.iter().all(|v| v.is_finite()) {
1249        return Err(ObjectiveEvalError::recoverable(format!(
1250            "{context}: objective returned a non-finite gradient"
1251        )));
1252    }
1253    Ok(())
1254}
1255
1256pub(crate) fn finite_outer_eval_or_error(
1257    context: &str,
1258    layout: OuterThetaLayout,
1259    eval: OuterEval,
1260) -> Result<OuterEval, ObjectiveEvalError> {
1261    validate_outer_first_order(context, layout, &eval)?;
1262    match &eval.hessian {
1263        HessianValue::Dense(hessian) => {
1264            layout.validate_hessian_shape(hessian, context)?;
1265            if !hessian.iter().all(|v| v.is_finite()) {
1266                return Err(ObjectiveEvalError::recoverable(format!(
1267                    "{context}: objective returned a non-finite Hessian"
1268                )));
1269            }
1270        }
1271        HessianValue::Operator(op) => {
1272            if op.dim() != layout.n_params {
1273                return Err(ObjectiveEvalError::recoverable(format!(
1274                    "{context}: outer Hessian operator dimension mismatch: got {}, expected {} (rho_dim={}, psi_dim={})",
1275                    op.dim(),
1276                    layout.n_params,
1277                    layout.rho_dim(),
1278                    layout.psi_dim
1279                )));
1280            }
1281        }
1282        HessianValue::Unavailable => {}
1283    }
1284    Ok(eval)
1285}
1286
1287pub(crate) fn finite_outer_first_order_eval_or_error(
1288    context: &str,
1289    layout: OuterThetaLayout,
1290    eval: OuterEval,
1291) -> Result<OuterEval, ObjectiveEvalError> {
1292    validate_outer_first_order(context, layout, &eval)?;
1293    Ok(eval)
1294}
1295
1296pub(crate) fn validate_second_order_seed_hessian(
1297    context: &str,
1298    layout: OuterThetaLayout,
1299    eval: &OuterEval,
1300) -> Result<(), ObjectiveEvalError> {
1301    if layout.n_params > SECOND_ORDER_GEOMETRY_PROBE_MAX_PARAMS || !eval.hessian.is_analytic() {
1302        return Ok(());
1303    }
1304    if matches!(
1305        &eval.hessian,
1306        HessianValue::Operator(op) if !op.materialization().is_available()
1307    ) {
1308        return Ok(());
1309    }
1310
1311    let Some(hessian) = eval.hessian.materialize_dense().map_err(|error| {
1312        ObjectiveEvalError::recoverable(format!(
1313            "{context}: analytic outer Hessian materialization failed during second-order seed validation: {error}"
1314        ))
1315    })?
1316    else {
1317        return Ok(());
1318    };
1319
1320    layout.validate_hessian_shape(&hessian, context)?;
1321    if !hessian.iter().all(|value| value.is_finite()) {
1322        return Err(ObjectiveEvalError::recoverable(format!(
1323            "{context}: analytic outer Hessian probe encountered non-finite entries"
1324        )));
1325    }
1326
1327    Ok(())
1328}
1329
1330// ─── Permutation-invariant outer coordinate canonicalization ──────────
1331//
1332// The additive-term-order (#1539) and tensor-margin-order (#1538) invariance
1333// bugs share one root cause: the outer smoothing-parameter optimizer resolves
1334// a flat double-penalty REML valley differently depending on the ORDER the
1335// penalty blocks are presented (seed placement, multistart, and tie-breaking
1336// all operate in native penalty-index order). The design and penalty are
1337// symmetric up to a block permutation, so the cure is permutation-invariance
1338// by construction: present the optimizer an identical CANONICAL coordinate
1339// layout regardless of native order, then map the optimized ρ back.
1340//
1341// The canonical order is a stable sort of the native coordinates by their
1342// structural key (see `PenaltyCoordinate::canonical_structural_key`), which is
1343// derived purely from each penalty's rotation-/placement-invariant content —
1344// never from its native position. Two formula orders therefore yield the SAME
1345// canonical layout, so the optimizer's seeding/multistart/tie-break all run on
1346// byte-identical coordinates and select identical λ̂.
1347
1348/// Canonical→native index map: `perm[c]` is the native coordinate placed at
1349/// canonical position `c`.
1350///
1351/// Returns `None` when the keys are already in canonical order (the permutation
1352/// is the identity), so the legacy native-order path runs untouched.
1353pub(crate) fn canonical_permutation(keys: &[u64]) -> Option<Vec<usize>> {
1354    let n = keys.len();
1355    if n <= 1 {
1356        return None;
1357    }
1358    let mut perm: Vec<usize> = (0..n).collect();
1359    // Stable sort by structural key. Ties (structurally interchangeable
1360    // coordinates) keep their native relative order — harmless precisely
1361    // because tied coordinates produce identical fits under any assignment.
1362    perm.sort_by_key(|&i| keys[i]);
1363    if perm.iter().enumerate().all(|(c, &i)| c == i) {
1364        None
1365    } else {
1366        Some(perm)
1367    }
1368}
1369
1370/// Reorder a native-layout ρ vector into canonical order: `out[c] = native[perm[c]]`.
1371fn permute_to_canonical(native: &Array1<f64>, perm: &[usize]) -> Array1<f64> {
1372    Array1::from_iter(perm.iter().map(|&i| native[i]))
1373}
1374
1375/// Reorder a canonical-layout ρ vector back into native order:
1376/// `out[perm[c]] = canonical[c]`.
1377fn permute_to_native(canonical: &Array1<f64>, perm: &[usize]) -> Array1<f64> {
1378    let mut out = Array1::zeros(canonical.len());
1379    for (c, &i) in perm.iter().enumerate() {
1380        out[i] = canonical[c];
1381    }
1382    out
1383}
1384
1385/// Map an `OuterResult` produced in CANONICAL coordinate order back to the
1386/// objective's native layout, in place. Permutes every per-coordinate array
1387/// (ρ, gradient, Hessian) consistently; scalar and diagnostic fields are
1388/// untouched.
1389pub(crate) fn outer_result_to_native(mut result: OuterResult, perm: &[usize]) -> OuterResult {
1390    if result.rho.len() == perm.len() {
1391        result.rho = permute_to_native(&result.rho, perm);
1392    }
1393    if let Some(g) = result.final_gradient.as_ref()
1394        && g.len() == perm.len()
1395    {
1396        result.final_gradient = Some(permute_to_native(g, perm));
1397    }
1398    if let Some(h) = result.final_hessian.as_ref()
1399        && h.nrows() == perm.len()
1400        && h.ncols() == perm.len()
1401    {
1402        // H_native[perm[a], perm[b]] = H_canon[a, b].
1403        let mut hn = Array2::<f64>::zeros((perm.len(), perm.len()));
1404        for (a, &ia) in perm.iter().enumerate() {
1405            for (b, &ib) in perm.iter().enumerate() {
1406                hn[[ia, ib]] = h[[a, b]];
1407            }
1408        }
1409        result.final_hessian = Some(hn);
1410    }
1411    result
1412}
1413
1414/// Wraps any [`OuterObjective`] so the optimizer can work in a CANONICAL
1415/// coordinate order while the wrapped objective continues to receive ρ in its
1416/// NATIVE order. The optimizer hands canonical ρ to this wrapper; the wrapper
1417/// permutes canonical→native before forwarding to the inner objective, so the
1418/// inner objective (and any checkpointing/cache layer beneath it) sees native
1419/// ρ exactly as before. Capability shape (`n_params`, `psi_dim`, …) is
1420/// unchanged — only coordinate order differs.
1421pub(crate) struct CanonicalizedObjective<'a> {
1422    inner: &'a mut dyn OuterObjective,
1423    /// Canonical→native map: `perm[c]` is the native index at canonical slot `c`.
1424    perm: Vec<usize>,
1425}
1426
1427impl<'a> CanonicalizedObjective<'a> {
1428    pub(crate) fn new(inner: &'a mut dyn OuterObjective, perm: Vec<usize>) -> Self {
1429        Self { inner, perm }
1430    }
1431
1432    #[inline]
1433    fn to_native(&self, canonical: &Array1<f64>) -> Array1<f64> {
1434        if canonical.len() == self.perm.len() {
1435            permute_to_native(canonical, &self.perm)
1436        } else {
1437            // Defensive: a length the permutation does not cover is forwarded
1438            // verbatim rather than corrupted (should not occur for ρ-coords).
1439            canonical.clone()
1440        }
1441    }
1442
1443    /// Map a native-order eval (gradient/Hessian) back into canonical order so
1444    /// the optimizer sees a self-consistent canonical objective.
1445    fn eval_to_canonical(&self, mut eval: OuterEval) -> OuterEval {
1446        if eval.gradient.len() == self.perm.len() {
1447            eval.gradient = permute_to_canonical(&eval.gradient, &self.perm);
1448        }
1449        eval.hessian = match eval.hessian {
1450            HessianValue::Dense(h)
1451                if h.nrows() == self.perm.len() && h.ncols() == self.perm.len() =>
1452            {
1453                let mut hc = Array2::<f64>::zeros((self.perm.len(), self.perm.len()));
1454                for (a, &ia) in self.perm.iter().enumerate() {
1455                    for (b, &ib) in self.perm.iter().enumerate() {
1456                        hc[[a, b]] = h[[ia, ib]];
1457                    }
1458                }
1459                HessianValue::Dense(hc)
1460            }
1461            other => other,
1462        };
1463        // `inner_beta_hint` is in the coefficient basis (not ρ-coordinate
1464        // order), so it is forwarded unchanged.
1465        eval
1466    }
1467}
1468
1469impl<'a> OuterObjective for CanonicalizedObjective<'a> {
1470    fn capability(&self) -> OuterCapability {
1471        self.inner.capability()
1472    }
1473
1474    fn terminal_eval_order(&self) -> Option<OuterEvalOrder> {
1475        self.inner.terminal_eval_order()
1476    }
1477
1478    fn owns_terminal_coefficient_mode(&self) -> bool {
1479        // Forward through the canonicalizing permutation wrapper so a cap-less
1480        // mode owner (e.g. a custom family) still gets the terminal reset when
1481        // its outer search runs in a non-identity canonical coordinate layout
1482        // (#2334). Ownership is coordinate-order-invariant.
1483        self.inner.owns_terminal_coefficient_mode()
1484    }
1485
1486    fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
1487        let native = self.to_native(rho);
1488        self.inner.eval_cost(&native)
1489    }
1490
1491    fn eval_screening_proxy(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
1492        let native = self.to_native(rho);
1493        self.inner.eval_screening_proxy(&native)
1494    }
1495
1496    fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
1497        let native = self.to_native(rho);
1498        let eval = self.inner.eval(&native)?;
1499        Ok(self.eval_to_canonical(eval))
1500    }
1501
1502    fn eval_with_order(
1503        &mut self,
1504        rho: &Array1<f64>,
1505        order: OuterEvalOrder,
1506    ) -> Result<OuterEval, EstimationError> {
1507        let native = self.to_native(rho);
1508        let eval = self.inner.eval_with_order(&native, order)?;
1509        Ok(self.eval_to_canonical(eval))
1510    }
1511
1512    fn eval_efs(&mut self, rho: &Array1<f64>) -> Result<EfsEval, EstimationError> {
1513        let native = self.to_native(rho);
1514        let mut efs = self.inner.eval_efs(&native)?;
1515        // `steps` has one entry per θ-coordinate (length = n_rho + n_ext). The
1516        // canonical permutation covers only the leading ρ-coordinate block, so
1517        // map exactly those native→canonical; any trailing ψ/ext steps keep
1518        // their position (the canonicalized path is ρ-only, psi_dim == 0).
1519        let m = self.perm.len();
1520        if efs.steps.len() >= m {
1521            let leading = Array1::from_iter(efs.steps.iter().take(m).copied());
1522            let canon_leading = permute_to_canonical(&leading, &self.perm);
1523            for (c, v) in canon_leading.iter().enumerate() {
1524                efs.steps[c] = *v;
1525            }
1526        }
1527        Ok(efs)
1528    }
1529
1530    fn eval_fixed_point_certificate(
1531        &mut self,
1532        rho: &Array1<f64>,
1533    ) -> Result<FixedPointCertificateEval, EstimationError> {
1534        let native = self.to_native(rho);
1535        let mut evaluation = self.inner.eval_fixed_point_certificate(&native)?;
1536        if evaluation.coordinates.len() == self.perm.len() {
1537            evaluation.coordinates = self
1538                .perm
1539                .iter()
1540                .map(|&native_index| evaluation.coordinates[native_index].clone())
1541                .collect();
1542        }
1543        Ok(evaluation)
1544    }
1545
1546    fn rail_face_limit(
1547        &mut self,
1548        rho: &Array1<f64>,
1549        face: &[usize],
1550    ) -> Result<RailFaceLimitOutcome, EstimationError> {
1551        // The face is a set of ρ-coordinates, so it permutes exactly like ρ.
1552        let native_rho = self.to_native(rho);
1553        let mut native_face = Vec::with_capacity(face.len());
1554        for &canonical in face.iter() {
1555            match self.perm.get(canonical).copied() {
1556                Some(native) => native_face.push(native),
1557                None => {
1558                    return Ok(RailFaceLimitOutcome::FaceUnavailable {
1559                        reason: format!(
1560                            "face coordinate {canonical} is outside the canonical permutation"
1561                        ),
1562                    });
1563                }
1564            }
1565        }
1566        let mut limit = match self.inner.rail_face_limit(&native_rho, &native_face)? {
1567            RailFaceLimitOutcome::Available(limit) => limit,
1568            declined => return Ok(declined),
1569        };
1570        // The inner objective reports its face in NATIVE indices (and may have
1571        // reordered it); map back so the certificate names canonical
1572        // coordinates, keeping every per-coordinate array aligned with it.
1573        let mut canonical_of_native = vec![usize::MAX; self.perm.len()];
1574        for (canonical, &native) in self.perm.iter().enumerate() {
1575            canonical_of_native[native] = canonical;
1576        }
1577        let mut canonical_face = Vec::with_capacity(limit.face.len());
1578        for &native in limit.face.iter() {
1579            match canonical_of_native.get(native).copied() {
1580                Some(canonical) if canonical != usize::MAX => canonical_face.push(canonical),
1581                _ => {
1582                    return Ok(RailFaceLimitOutcome::FaceUnavailable {
1583                        reason: format!(
1584                            "the reported face names native coordinate {native}, which the \
1585                             permutation does not cover"
1586                        ),
1587                    });
1588                }
1589            }
1590        }
1591        limit.face = canonical_face;
1592        Ok(RailFaceLimitOutcome::Available(limit))
1593    }
1594
1595    fn reset(&mut self) {
1596        self.inner.reset();
1597    }
1598
1599    fn begin_exact_polish(&mut self) -> bool {
1600        self.inner.begin_exact_polish()
1601    }
1602
1603    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
1604        // β is in the coefficient basis, not ρ-coordinate order — forward as-is.
1605        self.inner.seed_inner_state(beta)
1606    }
1607
1608    fn reactive_domain_scalar_contract(
1609        &self,
1610    ) -> Result<Option<crate::continuation_path::ContinuationScalarContract>, EstimationError> {
1611        self.inner.reactive_domain_scalar_contract()
1612    }
1613
1614    fn install_reactive_domain_scalar_state(
1615        &mut self,
1616        state: &crate::continuation_path::ContinuationScalarState,
1617    ) -> Result<(), EstimationError> {
1618        self.inner.install_reactive_domain_scalar_state(state)
1619    }
1620
1621    fn begin_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
1622        self.inner.begin_reactive_domain_waypoint()
1623    }
1624
1625    fn commit_reactive_domain_waypoint(
1626        &mut self,
1627        rho: &Array1<f64>,
1628    ) -> Result<(), EstimationError> {
1629        let native = self.to_native(rho);
1630        self.inner.commit_reactive_domain_waypoint(&native)
1631    }
1632
1633    fn rollback_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
1634        self.inner.rollback_reactive_domain_waypoint()
1635    }
1636
1637    fn accept_seed_without_outer_iterations(
1638        &mut self,
1639        rho: &Array1<f64>,
1640    ) -> Result<Option<f64>, EstimationError> {
1641        let native = self.to_native(rho);
1642        self.inner.accept_seed_without_outer_iterations(&native)
1643    }
1644
1645    fn curvature_homotopy_entry(
1646        &mut self,
1647        rho: &Array1<f64>,
1648    ) -> Option<Result<bool, EstimationError>> {
1649        let native = self.to_native(rho);
1650        self.inner.curvature_homotopy_entry(&native)
1651    }
1652
1653    fn finalize_outer_result(
1654        &mut self,
1655        rho: &Array1<f64>,
1656        plan: &OuterPlan,
1657    ) -> Result<(), EstimationError> {
1658        let native = self.to_native(rho);
1659        self.inner.finalize_outer_result(&native, plan)
1660    }
1661
1662    fn outer_device_admission(&self) -> Option<gam_gpu::policy::RemlOuterAdmission> {
1663        // The device path optimizes in its own coordinate layout; canonicalized
1664        // problems route through the host BFGS/ARC path (where the permutation
1665        // is honored) rather than the device driver.
1666        None
1667    }
1668}