Skip to main content

gam_solve/rho_optimizer/
objective.rs

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