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    /// The ρ-gradient of the soft numerical-guard BARRIER this objective adds
212    /// to its criterion, if it adds one (#2545).
213    ///
214    /// `None` — the default — means "this objective carries no such barrier",
215    /// and every consumer then behaves exactly as it did before this seam
216    /// existed. `Some(g)` must be the barrier's own gradient at `rho`, in the
217    /// same coordinate order as [`Self::eval`]'s gradient and of the same
218    /// length, computed by the SAME code path the criterion used to ADD it. It
219    /// must not be re-derived from the policy constants: the REML barrier is
220    /// evaluated at the weight-anchored coordinate `ρ̃ = ρ − log g(w)`, so a
221    /// closed form written against raw ρ agrees with the criterion only on
222    /// unweighted fits and disagrees on every weighted one.
223    ///
224    /// "Same length as [`Self::eval`]'s gradient" means the full θ, including
225    /// any trailing ψ/link block. The barrier acts on ρ only, so those entries
226    /// are EXACTLY zero — never omitted, and never filled with the ρ block
227    /// shifted along. [`ClosureObjective`] does that embedding from the
228    /// declared [`OuterThetaLayout`] so no implementor writes the arithmetic
229    /// (#2629).
230    ///
231    /// # What this is for, and the line it must not cross
232    ///
233    /// A `log cosh` barrier's gradient SATURATES at `w·a` instead of decaying,
234    /// so at an upper rail the KKT projection (`gi.max(0.0)`) retains exactly
235    /// that positive part and `|Pg| ≥ w·a` however clean the fit — a λ=∞ face
236    /// can never register as stationary. The certificate therefore subtracts
237    /// this term where the barrier provably is not part of the optimality
238    /// condition: at coordinates pinned to a box bound (the box enforces the
239    /// bound exactly, which is the barrier's entire job) and along the tail
240    /// probes (where the `ĉ = −e^ρ·∂V/∂ρ` law is a statement about the
241    /// criterion's data term, and the barrier is a known additive analytic
242    /// term on top of it).
243    ///
244    /// It deliberately does NOT subtract at an INTERIOR stationarity test.
245    /// There the optimizer descends the criterion WITH the barrier and stops
246    /// where their sum vanishes; a certificate that judged the sum minus the
247    /// barrier would judge a different function than was optimized and
248    /// manufacture "solver converged, certificate refused" out of the
249    /// disagreement.
250    fn soft_rho_guard_gradient(&mut self, rho: &Array1<f64>) -> Option<Array1<f64>> {
251        log::trace!(
252            "[#2545] this objective declares no soft rho-guard barrier (rho_dim={})",
253            rho.len()
254        );
255        None
256    }
257
258    /// Directions of the outer coordinate along which this criterion is EXACTLY
259    /// constant by construction, at `theta` (#2676).
260    ///
261    /// Orthonormal columns, `theta.len() x d`, or `None` when the objective
262    /// declares no such invariance — which is the default and which reproduces
263    /// every pre-#2676 verdict bit for bit.
264    ///
265    /// A penalized criterion sees `lambda` only through
266    /// `sum_i lambda_i (beta - mu_i)' S_i (beta - mu_i)`, so any `w` with
267    /// `sum_i w_i S_i = 0` (plus the two conditions a nonzero `mu_i` imposes)
268    /// leaves it unchanged along `lambda + s w`. Lifted to `rho = log lambda`
269    /// by `t = diag(lambda)^{-1} w`, the exact chain rule
270    /// `H_rho = diag(lambda) H_lambda diag(lambda) + diag(g_rho)` gives
271    /// `t' H_rho t = sum_k g_k t_k^2` — the curvature there is a function of the
272    /// GRADIENT, which the certificate has separately judged against its
273    /// stationarity bound. Judging it again as curvature, against a floor that
274    /// is the same quantity's absolute value, decides the certificate on a
275    /// rounding residual.
276    ///
277    /// The certificate therefore deflates these directions before any PSD test
278    /// and judges their orthogonal complement by the unchanged rule. See
279    /// [`crate::penalty_invariance`] for the derivation, what deflating cannot
280    /// hide, and why a wider floor is the wrong answer.
281    ///
282    /// # Who has opted in, and who has not
283    ///
284    /// Installed by the two REML arms and the spatial joint arm, whose criterion
285    /// is built on a [`gam_terms::construction::CanonicalPenalty`] bundle that
286    /// `PenaltyMapInvariance` reads directly.
287    ///
288    /// NOT installed on two routes that also set `require_measured_psd`,
289    /// because on both of them the map from the penalty layout onto the outer
290    /// rho vector is not derivable from where the objective is built — and a
291    /// WRONG map deflates a direction the criterion is not flat along, which is
292    /// strictly worse than deflating nothing:
293    ///
294    /// * the **custom-family** route, whose penalties live as
295    ///   `ParameterBlockSpec::penalties` plus (for the one family that has them)
296    ///   a `JointPenaltyBundle`;
297    /// * the **n-block exact-joint spatial** route, whose criterion is a
298    ///   caller-supplied evaluator (`exact_fn`) rather than a `RemlState`, so
299    ///   the block-major concatenation of per-block penalties into rho is the
300    ///   caller's contract and not visible here.
301    ///
302    /// Both keep the default, which is exactly the pre-#2676 behaviour, so
303    /// nothing on them regresses. Neither carries a #2676 fixture: the
304    /// `geo_disease_*_matern` cells route through `standard REML` and
305    /// `iso-kappa joint REML`, both of which do install it.
306    fn criterion_invariant_directions(&mut self, theta: &Array1<f64>) -> Option<Array2<f64>> {
307        log::trace!(
308            "[#2676] this objective declares no criterion invariance (theta_dim={})",
309            theta.len()
310        );
311        None
312    }
313
314    /// Restore to a clean baseline for the next multi-start candidate.
315    fn reset(&mut self);
316
317    /// Whether this objective owns a terminal *coefficient* mode whose bitwise
318    /// identity fit assembly will later bind against the certified outer value.
319    ///
320    /// The certification sequence (`run.rs`) installs the terminal state twice
321    /// at `result.rho`: once via [`Self::finalize_outer_result`] (which the
322    /// mode-owning evaluator uses to install its coefficient mode) and once via
323    /// the analytic re-evaluation inside `certify_outer_optimality` (which sets
324    /// `result.final_value`). On a nonconvex profiled objective those two
325    /// evaluations can settle in *different* coefficient basins unless each is
326    /// forced to re-install from the same clean baseline through [`Self::reset`]
327    /// — otherwise they prime the inner solve off whatever warm state the
328    /// preceding diagnostic/finalize left behind, and the mode's objective and
329    /// the certified value disagree by a whole basin (measured: `9.1931e2` vs
330    /// `9.1671e2` on the cause-specific survival gate).
331    ///
332    /// That terminal reset is otherwise gated on `config.outer_inner_cap`,
333    /// which the REML/mixture objectives wire but the custom-family (and any
334    /// other terminal-mode-owning closure) objective does not — it holds its
335    /// inner cap in a different field and leaves `outer_inner_cap` `None`, so
336    /// the reset never fires and the bitwise bind can spuriously fail on a
337    /// bimodal inner solve. Returning `true` here forces the terminal reset
338    /// *independently of the cap*, so `finalize` and `certify` provably come
339    /// from one fresh evaluation at `rho_star`. It deliberately does NOT touch
340    /// the `inner_solve_converged(config.outer_inner_cap)` gate: an objective
341    /// that owns a terminal mode but does not populate the cap's convergence
342    /// atomic keeps its own stateful convergence semantics.
343    ///
344    /// The default is `false`: an objective that owns no terminal coefficient
345    /// mode (the reactive-domain fixture among them) retains the very state its
346    /// evaluation at `result.rho` depends on and must not be reset.
347    fn owns_terminal_coefficient_mode(&self) -> bool {
348        false
349    }
350
351    /// Transition an objective that actually used an approximate derivative
352    /// pilot to its exact full-data measure.
353    ///
354    /// The runner calls this once after the pilot solver returns a checkpoint.
355    /// `true` means the objective changed measure and must be optimized again
356    /// from that checkpoint before analytic certification. Exact objectives and
357    /// pilots that never installed a sample return `false`.
358    fn begin_exact_polish(&mut self) -> bool {
359        false
360    }
361
362    /// Seed the inner-solver iterate before the first eval, e.g. when the
363    /// outer-iterate cache restored a `(ρ, β)` pair from a prior run, or
364    /// when a typed reactive continuation path forwards
365    /// `OuterEval::inner_beta_hint`
366    /// from the previous step.
367    ///
368    /// Objectives make an explicit choice via the [`SeedOutcome`] return:
369    /// implementations with an inner β slot return [`SeedOutcome::Installed`]
370    /// after storing β; implementations without one return
371    /// [`SeedOutcome::NoSlot`]. Genuine seeding failures (wrong dimension
372    /// when a slot exists, etc.) are reported via `Err(EstimationError)`.
373    ///
374    /// Callers that need to distinguish "no slot" from "installed" (the
375    /// outer cache warm-start path, which logs cache provenance) branch on
376    /// the variant. Callers that don't care (the reactive continuation path,
377    /// which only proceeds cold when the hint is unusable) ignore it and only
378    /// propagate `Err`.
379    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError>;
380
381    /// Optional objective-owned hard upper domain for the outer coordinates.
382    ///
383    /// The generic optimizer intersects this vector with its configured box
384    /// before projecting seeds, constructing a solver, or opening reactive
385    /// continuation. Consequently the exact same upper endpoint is both the
386    /// solver's legal box face and the continuation path's literal rho entry.
387    /// `None` means the objective has no domain narrower than the configured
388    /// generic box. An advertised vector must have `capability().n_params`
389    /// finite entries; malformed contracts are typed runner errors.
390    fn outer_domain_upper_bound(&self) -> Result<Option<Array1<f64>>, EstimationError> {
391        Ok(None)
392    }
393
394    /// Optional objective-owned hard lower domain for the outer coordinates.
395    ///
396    /// This is intersected with the caller's configured box at the same single
397    /// runner seam as [`Self::outer_domain_upper_bound`], before any seed,
398    /// continuation waypoint, solver evaluation, or stationarity certificate can
399    /// observe an out-of-domain coordinate.
400    fn outer_domain_lower_bound(&self) -> Result<Option<Array1<f64>>, EstimationError> {
401        Ok(None)
402    }
403
404    /// Optional opt-in to the device-resident outer REML BFGS-over-ρ driver
405    /// (`crate::gpu::reml_outer::run_reml_outer_on_device`). Returns
406    /// `Some(adm)` when the objective is a REML evaluator whose
407    /// `(spec, n, p, num_rho)` admission predicate accepts the device path,
408    /// and `None` otherwise.
409    ///
410    /// The default returns `None` so non-REML objectives (line-search-only
411    /// inner bridges, screening proxies, the EFS / hybrid-EFS sub-objectives)
412    /// keep the host BFGS branch unconditionally — only the concrete
413    /// REML-state objectives override this to consult
414    /// `crate::estimate::reml::outer_eval::outer_reml_device_admission`.
415    fn outer_device_admission(&self) -> Option<gam_gpu::policy::RemlOuterAdmission> {
416        None
417    }
418
419    /// Typed scalar continuation contract for repairing a non-finite literal
420    /// outer seed through [`crate::continuation_path::ContinuationPath`].
421    ///
422    /// This is a typed domain-entry capability, not a fallback objective. The
423    /// objective supplies both the smoother entry state and its literal target
424    /// state. `None` means this objective has no such domain homotopy. The
425    /// runner always probes the real seed first, so merely supplying a contract
426    /// performs no waypoint installation or heavy work on a finite seed.
427    fn reactive_domain_scalar_contract(
428        &self,
429    ) -> Result<Option<crate::continuation_path::ContinuationScalarContract>, EstimationError> {
430        Ok(None)
431    }
432
433    /// Install one scalar waypoint before the continuation rho spine evaluates
434    /// the objective. Objectives that return `Some` from
435    /// [`Self::reactive_domain_scalar_contract`] must override this method; the
436    /// default is a typed contract refusal, never a silent no-op.
437    fn install_reactive_domain_scalar_state(
438        &mut self,
439        state: &crate::continuation_path::ContinuationScalarState,
440    ) -> Result<(), EstimationError> {
441        Err(EstimationError::RemlOptimizationFailed(format!(
442            "objective supplied a reactive-domain scalar contract but cannot install its \
443             waypoint (temperature={}, isometry_dim={})",
444            state.assignment_temperature,
445            state.isometry_weights.len(),
446        )))
447    }
448
449    /// Snapshot the objective's complete accepted inner state before a reactive
450    /// coupled waypoint is installed. Contract-advertising objectives must make
451    /// this transactional: a failed trial is restored by
452    /// [`Self::rollback_reactive_domain_waypoint`].
453    fn begin_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
454        Err(EstimationError::RemlOptimizationFailed(
455            "objective supplied a reactive-domain scalar contract but cannot checkpoint a waypoint"
456                .to_string(),
457        ))
458    }
459
460    /// Commit the converged full inner state produced by the value evaluation
461    /// at `rho`. A coefficient-only handoff is insufficient: latent coordinates,
462    /// routing logits, decoder frames, loss, and scalar state must advance as one
463    /// accepted waypoint.
464    fn commit_reactive_domain_waypoint(
465        &mut self,
466        rho: &Array1<f64>,
467    ) -> Result<(), EstimationError> {
468        Err(EstimationError::RemlOptimizationFailed(format!(
469            "objective supplied a reactive-domain scalar contract but cannot commit a waypoint \
470             (rho_dim={})",
471            rho.len(),
472        )))
473    }
474
475    /// Restore the full accepted state saved by
476    /// [`Self::begin_reactive_domain_waypoint`] after an errored or non-finite
477    /// trial.
478    fn rollback_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
479        Err(EstimationError::RemlOptimizationFailed(
480            "objective supplied a reactive-domain scalar contract but cannot roll back a waypoint"
481                .to_string(),
482        ))
483    }
484
485    /// Run the objective's certified curvature-homotopy entry leg, if it has
486    /// one, leaving the inner state warm at the real (`η = 1`) objective.
487    ///
488    /// An objective with a *certified anchor* — a point known by construction to
489    /// be the global optimum of a relaxed problem — can replace the blind
490    /// multi-seed multistart with a single predictor-corrector walk from that
491    /// anchor to the true objective (#1007). The SAE-manifold objective
492    /// overrides this: its `η = 0` base-topology relaxation is convex, and a
493    /// genuine low-rank (Eckart-Young / SVD) residual ceiling is certified by
494    /// `linear_span_anchor` — the `η = 0` endpoint is NOT a linear/affine model
495    /// (for curved bases its base columns still embed curvature); "Eckart-Young"
496    /// names the rank ceiling, not the chart. The walk in `η` tracks the unique
497    /// optimal branch to `η = 1`. The walk monitors the
498    /// arrow-factor min-pivot and halves the `η` step when it shrinks; a pivot
499    /// collapse below tolerance is a DETECTED bifurcation (recorded on the fit
500    /// payload, never silent), at which point the objective falls back to the
501    /// documented multi-seed cascade.
502    ///
503    /// Returns:
504    ///   * `None` — no certified anchor; use the standard seed cascade
505    ///     (the default for every other objective).
506    ///   * `Some(Ok(true))` — the walk arrived; the inner state is warm at the
507    ///     certified `η = 1` solution and the seed cascade is bypassed.
508    ///   * `Some(Ok(false))` — the anchor degenerated or the walk detected a
509    ///     bifurcation; fall back to the multi-seed cascade (the report is
510    ///     recorded on the objective for the fit payload).
511    ///   * `Some(Err(_))` — a hard failure constructing the anchor.
512    fn curvature_homotopy_entry(
513        &mut self,
514        rho: &Array1<f64>,
515    ) -> Option<Result<bool, EstimationError>> {
516        // Default: no certified anchor — but a non-finite seed is reported
517        // here rather than silently handed to the seed cascade, mirroring the
518        // hard-failure contract of the overriding implementations.
519        if let Some(idx) = rho.iter().position(|v| !v.is_finite()) {
520            return Some(Err(EstimationError::RemlOptimizationFailed(format!(
521                "curvature-homotopy entry received non-finite rho[{idx}]"
522            ))));
523        }
524        None
525    }
526
527    /// Let an objective declare that a seed is already a terminal outer result.
528    /// Used for objectives with a certified high-quality construction seed where
529    /// the generic rho optimizer can only degrade the fitted state.
530    fn accept_seed_without_outer_iterations(
531        &mut self,
532        rho: &Array1<f64>,
533    ) -> Result<Option<f64>, EstimationError> {
534        if rho.is_empty() {
535            return Ok(None);
536        }
537        Ok(None)
538    }
539
540    /// Optional analytic evaluation order that must own the final installed
541    /// objective state, independently of the solver plan that found `rho`.
542    ///
543    /// The default follows the solver (`EFS` finalizes through `eval_efs`,
544    /// BFGS through first order, ARC through second order). Stateful profiled
545    /// objectives may override this when only one evaluator produces the
546    /// ownership payload consumed by fit assembly.
547    fn terminal_eval_order(&self) -> Option<OuterEvalOrder> {
548        None
549    }
550
551    /// Re-install the selected outer result into the mutable objective before
552    /// callers consume objective-owned fitted state. Optimizers may evaluate
553    /// rejected trial points after the best point was found; without this final
554    /// synchronization, stateful objectives can report the last trial fit rather
555    /// than the returned `OuterResult::rho`.
556    fn finalize_outer_result(
557        &mut self,
558        rho: &Array1<f64>,
559        plan: &OuterPlan,
560    ) -> Result<(), EstimationError> {
561        log::debug!(
562            "[OUTER] finalize: re-installing best rho into the objective (solver {:?})",
563            plan.solver
564        );
565        let order = self.terminal_eval_order().or(match plan.solver {
566            Solver::Efs | Solver::HybridEfs => None,
567            Solver::Bfgs => Some(OuterEvalOrder::ValueAndGradient),
568            Solver::Arc => Some(OuterEvalOrder::ValueGradientHessian),
569        });
570        match order {
571            Some(order) => {
572                self.eval_with_order(rho, order)?;
573            }
574            None => {
575                self.eval_efs(rho)?;
576            }
577        }
578        Ok(())
579    }
580}
581
582// ─── Persistent warm-start checkpoint plumbing ────────────────────────
583//
584// `CheckpointingObjective` wraps any `OuterObjective` to write a copy of
585// `(rho, cost, eval_id)` to disk on each finite evaluation. The on-disk
586// [`gam_runtime::warm_start::Session`] rate-limits writes (≥2 s gap unless this iterate
587// strictly improves on the best-so-far) so a tight inner loop never thrashes
588// the filesystem. The same checkpoint is also broadcast to optional mirror
589// sessions, which lets interrupted exact-key runs seed later related fits via
590// their prefix key instead of waiting for a final converged write.
591
592#[derive(serde::Serialize, serde::Deserialize)]
593pub(crate) struct IteratePayload {
594    /// Bump on incompatible payload changes; decode rejects mismatches.
595    schema: u32,
596    pub(crate) rho: Vec<f64>,
597    /// Inner-solver iterate (PIRLS β) captured alongside ρ. The (ρ, β)
598    /// pair lives on the implicit-function manifold β = β*(ρ); restoring
599    /// ρ alone forces the next inner solve to reconstruct β from scratch.
600    /// For saturated ρ (|ρ_i| near `rho_bound`) the inner Hessian
601    /// `X'WX + Σ λ_i S_i` has condition number `≈ e^{2·rho_bound}` — Newton
602    /// degrades to O(1/k) descent and the cycle budget exhausts before
603    /// KKT. Caching β lets the resume start in Newton's quadratic basin
604    /// regardless of where ρ lives. Empty when the family did not surface
605    /// an inner-β hint at write time (still useful as a ρ-only seed).
606    #[serde(default)]
607    pub(crate) beta: Vec<f64>,
608    /// Converged exact outer curvature `H(θ̂)` (full θ×θ, row-major flatten),
609    /// captured alongside the (ρ, β) iterate. A gradient-based BFGS solve does
610    /// not surface its accumulated inverse-Hessian, so the next
611    /// structurally-matching fit (e.g. the next LOSO fold) otherwise restarts
612    /// BFGS from an unscaled identity metric and rediscovers curvature through
613    /// line-search bracketing — multiple full inner-solve value probes per
614    /// accepted outer step. Persisting the converged curvature lets the resume
615    /// seed `InitialMetric::DenseInverseHessian(H⁻¹)` for a quasi-Newton first
616    /// step. Empty when no exact outer Hessian was available at write time
617    /// (still a valid ρ/β seed). `hessian_dim²` must equal `hessian.len()`.
618    #[serde(default)]
619    pub(crate) hessian: Vec<f64>,
620    /// Side length of the square `hessian` matrix (`hessian.len() == dim²`).
621    /// Zero when no Hessian was persisted.
622    #[serde(default)]
623    pub(crate) hessian_dim: usize,
624    pub(crate) cost: f64,
625    eval_id: u64,
626}
627
628/// Entries with a different schema id are rejected by `decode_iterate`
629/// so incompatible on-disk payloads fall through to cold start instead
630/// of seeding the inner solve with a malformed iterate.
631/// Schema 3 invalidates every payload written before outer-Hessian provenance
632/// was tied to the objective's declared analytic capability. In particular,
633/// schema-2 SAE checkpoints may contain the now-deleted finite-difference
634/// curvature and must never influence a resumed quasi-Newton metric (#2253).
635pub(crate) const ITERATE_PAYLOAD_SCHEMA: u32 = 3;
636
637pub(crate) fn encode_iterate(
638    rho: &Array1<f64>,
639    beta: Option<&Array1<f64>>,
640    hessian: Option<&Array2<f64>>,
641    cost: f64,
642    eval_id: u64,
643) -> Option<Vec<u8>> {
644    // Persist the converged outer curvature only when it is square and finite;
645    // a non-finite or non-square Hessian is dropped (the resume falls back to a
646    // ρ/β-only seed) so a malformed curvature can never corrupt a warm start.
647    let (hessian_flat, hessian_dim) = match hessian {
648        Some(h) if h.nrows() == h.ncols() && h.iter().all(|v| v.is_finite()) => {
649            (h.iter().copied().collect::<Vec<f64>>(), h.nrows())
650        }
651        _ => (Vec::new(), 0),
652    };
653    let p = IteratePayload {
654        schema: ITERATE_PAYLOAD_SCHEMA,
655        rho: rho.to_vec(),
656        beta: beta.map(|b| b.to_vec()).unwrap_or_default(),
657        hessian: hessian_flat,
658        hessian_dim,
659        cost,
660        eval_id,
661    };
662    serde_json::to_vec(&p).ok()
663}
664
665pub(crate) fn decode_iterate(bytes: &[u8], expected_rho_dim: usize) -> Option<IteratePayload> {
666    let mut p: IteratePayload = serde_json::from_slice(bytes).ok()?;
667    if p.schema != ITERATE_PAYLOAD_SCHEMA {
668        return None;
669    }
670    if p.rho.len() != expected_rho_dim {
671        return None;
672    }
673    if !p.rho.iter().all(|x| x.is_finite()) || !p.cost.is_finite() {
674        return None;
675    }
676    if !p.beta.iter().all(|x| x.is_finite()) {
677        return None;
678    }
679    // A persisted Hessian must be square (`dim²` entries) and finite to be
680    // usable as a warm-start metric; an inconsistent or non-finite curvature is
681    // scrubbed to "no Hessian" rather than rejecting the whole iterate, so the
682    // ρ/β seed still warms the resume.
683    if p.hessian_dim.saturating_mul(p.hessian_dim) != p.hessian.len()
684        || !p.hessian.iter().all(|x| x.is_finite())
685    {
686        p.hessian = Vec::new();
687        p.hessian_dim = 0;
688    }
689    Some(p)
690}
691
692/// Outcome of inspecting a cache entry as a seed for the outer optimizer.
693///
694/// The classifier rejects only entries that fail structural validity
695/// (wrong dimension, non-finite payload). It does NOT reshape ρ based on
696/// saturation: every finite, well-shaped entry is honored as the next
697/// run's seed.
698///
699/// Previously this enum carried `saturated_coords` / `clamped_to` /
700/// "all-coords-saturated-poisoned-entry" branches that pulled boundary
701/// ρ inward or discarded fully-saturated entries. Those were read-side
702/// band-aids over the real bug: the warm-start contract stored ρ but
703/// not β, so resuming at boundary ρ forced PIRLS to recompute β from
704/// cold-start against a Hessian with condition number `≈ e^{2·rho_bound}`,
705/// and Newton degraded to O(1/k) descent that exhausted the cycle budget.
706///
707/// The contract is now `(ρ, β)`: the current iterate payload carries
708/// both, and [`CheckpointingObjective`] refuses to persist a divergent
709/// inner state (non-finite cost or β). Boundary ρ — when written under
710/// the new invariant — is a *legitimate* finding (the smoothness wants
711/// to be near-null), and the cached β puts the next inner solve at the
712/// previously converged iterate where the gradient is already at zero.
713/// No clamp or shape-based discard is needed.
714#[derive(Debug)]
715pub(crate) enum CacheSeedDecision {
716    ExactFinal {
717        rho: Array1<f64>,
718        /// Optional inner β captured at the converged ρ. Empty when the
719        /// payload didn't carry one (legacy ρ-only writes or families
720        /// that don't surface β).
721        beta: Vec<f64>,
722        iterations: usize,
723        prior_obj_display: f64,
724    },
725    Seed {
726        rho: Array1<f64>,
727        /// Optional inner β to prime the next run's inner solver via
728        /// [`OuterObjective::seed_inner_state`]. When non-empty, the
729        /// dispatcher injects β before the first eval so the inner
730        /// PIRLS opens at zero-gradient regardless of where ρ sits in
731        /// the box.
732        beta: Vec<f64>,
733        /// Optional converged outer Hessian `H(θ̂)` from the prior fit, as a
734        /// `(dim, row-major flatten)` pair. `None` when the payload carried no
735        /// curvature (legacy ρ/β-only writes). Seeds the BFGS iter-0 metric on
736        /// the resume so the first outer step is quasi-Newton.
737        hessian: Option<(usize, Vec<f64>)>,
738        prior_obj_display: f64,
739        iteration: u64,
740    },
741    Discard {
742        reason: &'static str,
743        prior_obj_display: f64,
744        all_rho_finite: Option<bool>,
745    },
746}
747
748pub(crate) fn classify_cache_entry_for_outer(
749    loaded: &gam_runtime::warm_start::LoadedEntry,
750    expected_rho_dim: usize,
751) -> CacheSeedDecision {
752    let entry = &loaded.entry;
753    let Some(payload) = decode_iterate(&entry.payload, expected_rho_dim) else {
754        return CacheSeedDecision::Discard {
755            reason: "payload-shape-mismatch",
756            prior_obj_display: entry.objective.unwrap_or(f64::NAN),
757            all_rho_finite: None,
758        };
759    };
760    let cached_rho = Array1::from_vec(payload.rho);
761    let prior_obj_display = entry.objective.unwrap_or(f64::NAN);
762    if matches!(entry.objective, Some(v) if !v.is_finite()) {
763        return CacheSeedDecision::Discard {
764            reason: "non-finite-payload",
765            prior_obj_display,
766            all_rho_finite: Some(cached_rho.iter().all(|v| v.is_finite())),
767        };
768    }
769    if !cached_rho.iter().all(|v| v.is_finite()) {
770        return CacheSeedDecision::Discard {
771            reason: "non-finite-payload",
772            prior_obj_display,
773            all_rho_finite: Some(false),
774        };
775    }
776    if loaded.source == LoadSource::Exact && entry.kind == gam_runtime::warm_start::EntryKind::Final
777    {
778        return CacheSeedDecision::ExactFinal {
779            rho: cached_rho,
780            beta: payload.beta,
781            iterations: entry
782                .iteration
783                .unwrap_or(payload.eval_id)
784                .min(usize::MAX as u64) as usize,
785            prior_obj_display,
786        };
787    }
788    let hessian = if payload.hessian_dim > 0
789        && payload.hessian.len() == payload.hessian_dim * payload.hessian_dim
790    {
791        Some((payload.hessian_dim, payload.hessian))
792    } else {
793        None
794    };
795    CacheSeedDecision::Seed {
796        rho: cached_rho,
797        beta: payload.beta,
798        hessian,
799        prior_obj_display,
800        iteration: entry.iteration.unwrap_or(payload.eval_id),
801    }
802}
803
804pub fn cache_entry_would_help_outer(
805    loaded: &gam_runtime::warm_start::LoadedEntry,
806    expected_rho_dim: usize,
807) -> bool {
808    matches!(
809        classify_cache_entry_for_outer(loaded, expected_rho_dim),
810        CacheSeedDecision::ExactFinal { .. } | CacheSeedDecision::Seed { .. }
811    )
812}
813
814pub(crate) struct CheckpointingObjective<'a> {
815    inner: &'a mut dyn OuterObjective,
816    session: Arc<CacheSession>,
817    mirror_sessions: Vec<Arc<CacheSession>>,
818    eval_counter: AtomicU64,
819    /// Most-recent exact outer/inner state surfaced by one beta-bearing
820    /// evaluation. Keeping ρ beside β is load-bearing: scalar certification
821    /// probes carry no β, so a bare "last β" can otherwise be paired with a
822    /// later certified ρ that never produced it (#2486).
823    last_inner_state: std::sync::Mutex<Option<(Array1<f64>, Array1<f64>)>>,
824    /// True only while the typed reactive-domain path evaluates an
825    /// initialization waypoint. Those waypoints are transactional means of
826    /// reaching the literal requested model, not candidate outer iterates, so
827    /// they must never become persistent restart seeds.
828    reactive_waypoint_active: AtomicBool,
829}
830
831impl<'a> CheckpointingObjective<'a> {
832    pub(crate) fn new(
833        inner: &'a mut dyn OuterObjective,
834        session: Arc<CacheSession>,
835        mirror_sessions: Vec<Arc<CacheSession>>,
836    ) -> Self {
837        Self {
838            inner,
839            session,
840            mirror_sessions,
841            eval_counter: AtomicU64::new(0),
842            last_inner_state: std::sync::Mutex::new(None),
843            reactive_waypoint_active: AtomicBool::new(false),
844        }
845    }
846
847    pub(crate) fn inner_beta_for(&self, rho: &Array1<f64>) -> Option<Array1<f64>> {
848        let guard = self.last_inner_state.lock().ok()?;
849        beta_for_exact_rho(guard.as_ref(), rho)
850    }
851
852    /// The per-evaluation `(ρ, V, ∇V)` trail, at `debug`.
853    ///
854    /// Every outer objective the runner drives is wrapped here, so this is the
855    /// one place that sees EVERY evaluation of EVERY route — the line search's
856    /// trial points included. Without it, a `line_search=StepSizeTooSmall`
857    /// refusal ("the direction descended but no step improved the objective")
858    /// can only be investigated by rebuilding the design outside the fit and
859    /// hoping the reconstruction is the same criterion; #2748's `haberman_5yr`
860    /// arm spent a full measurement cycle discovering that a faithful-looking
861    /// rebuild disagreed with the fit's own `|g|` by 35x. The trail settles
862    /// that question from inside the fit, in the coordinates the solver uses.
863    ///
864    /// `ρ` is printed in full: the whole point is to be able to difference two
865    /// consecutive trial points by hand, and a norm cannot be differenced.
866    fn trace_eval(&self, rho: &Array1<f64>, cost: f64, gradient: Option<&Array1<f64>>, what: &str) {
867        if !log::log_enabled!(log::Level::Debug) {
868            return;
869        }
870        let gradient_norm = gradient.map_or(f64::NAN, |g| g.dot(g).sqrt());
871        log::debug!(
872            "[OUTER eval] #{} {what} cost={cost:.15e} |g|={gradient_norm:.6e} rho={:?}",
873            self.eval_counter.load(Ordering::Relaxed),
874            rho.to_vec(),
875        );
876    }
877
878    fn note(&self, rho: &Array1<f64>, beta: Option<&Array1<f64>>, cost: f64) {
879        if self.reactive_waypoint_active.load(Ordering::Relaxed) {
880            return;
881        }
882        if !cost.is_finite() {
883            return;
884        }
885        // If β is provided, require it to be finite; non-finite β is a
886        // divergent inner state — persisting it would re-poison the cache.
887        if let Some(b) = beta {
888            if !b.iter().all(|v| v.is_finite()) {
889                return;
890            }
891            if let Ok(mut guard) = self.last_inner_state.lock() {
892                *guard = Some((rho.clone(), b.clone()));
893            }
894        }
895        let i = self.eval_counter.fetch_add(1, Ordering::Relaxed);
896        // Per-eval checkpoints carry no converged outer Hessian (curvature is
897        // only meaningful at the final optimum); the finalize write is where the
898        // converged `H(θ̂)` is persisted for cross-fit warm starts.
899        if let Some(bytes) = encode_iterate(rho, beta, None, cost, i) {
900            self.session.checkpoint(&bytes, Some(cost), Some(i));
901            for mirror in &self.mirror_sessions {
902                mirror.checkpoint(&bytes, Some(cost), Some(i));
903            }
904        }
905    }
906}
907
908fn beta_for_exact_rho(
909    state: Option<&(Array1<f64>, Array1<f64>)>,
910    rho: &Array1<f64>,
911) -> Option<Array1<f64>> {
912    let (producing_rho, beta) = state?;
913    (producing_rho.len() == rho.len()
914        && producing_rho
915            .iter()
916            .zip(rho.iter())
917            .all(|(left, right)| left.to_bits() == right.to_bits()))
918    .then(|| beta.clone())
919}
920
921#[cfg(test)]
922mod checkpoint_state_pair_tests {
923    use super::*;
924
925    #[test]
926    fn finalized_beta_requires_its_exact_producing_rho_2486() {
927        let state = (
928            Array1::from_vec(vec![1.0, -0.0]),
929            Array1::from_vec(vec![3.0, 4.0]),
930        );
931
932        assert_eq!(
933            beta_for_exact_rho(Some(&state), &Array1::from_vec(vec![1.0, -0.0])),
934            Some(Array1::from_vec(vec![3.0, 4.0])),
935        );
936        assert!(
937            beta_for_exact_rho(Some(&state), &Array1::from_vec(vec![1.0, 0.0])).is_none(),
938            "even numerically equal but bit-distinct rho cannot borrow another evaluation's beta",
939        );
940        assert!(
941            beta_for_exact_rho(Some(&state), &Array1::from_vec(vec![1.0])).is_none(),
942            "a shape-mismatched rho cannot borrow beta",
943        );
944    }
945}
946
947impl<'a> OuterObjective for CheckpointingObjective<'a> {
948    fn capability(&self) -> OuterCapability {
949        self.inner.capability()
950    }
951
952    fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
953        let v = self.inner.eval_cost(rho)?;
954        self.trace_eval(rho, v, None, "value");
955        // `eval_cost` carries no inner-β handle — persist ρ-only.
956        self.note(rho, None, v);
957        Ok(v)
958    }
959
960    fn eval_screening_proxy(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
961        // Screening proxies run at sub-converged β̂ and aren't a meaningful
962        // best-so-far signal; forward without persisting.
963        self.inner.eval_screening_proxy(rho)
964    }
965
966    fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
967        let r = self.inner.eval(rho)?;
968        self.trace_eval(rho, r.cost, Some(&r.gradient), "value+gradient");
969        self.note(rho, r.inner_beta_hint.as_ref(), r.cost);
970        Ok(r)
971    }
972
973    fn eval_with_order(
974        &mut self,
975        rho: &Array1<f64>,
976        order: OuterEvalOrder,
977    ) -> Result<OuterEval, EstimationError> {
978        let r = self.inner.eval_with_order(rho, order)?;
979        self.trace_eval(rho, r.cost, Some(&r.gradient), &format!("{order:?}"));
980        self.note(rho, r.inner_beta_hint.as_ref(), r.cost);
981        Ok(r)
982    }
983
984    fn eval_efs(&mut self, rho: &Array1<f64>) -> Result<EfsEval, EstimationError> {
985        let r = self.inner.eval_efs(rho)?;
986        // EfsEval has no inner-β hint surface yet — persist ρ-only.
987        self.note(rho, None, r.cost);
988        Ok(r)
989    }
990
991    fn eval_fixed_point_certificate(
992        &mut self,
993        rho: &Array1<f64>,
994    ) -> Result<FixedPointCertificateEval, EstimationError> {
995        let r = self.inner.eval_fixed_point_certificate(rho)?;
996        self.note(rho, None, r.cost);
997        Ok(r)
998    }
999
1000    fn rail_face_limit(
1001        &mut self,
1002        rho: &Array1<f64>,
1003        face: &[usize],
1004    ) -> Result<RailFaceLimitOutcome, EstimationError> {
1005        self.inner.rail_face_limit(rho, face)
1006    }
1007
1008    fn soft_rho_guard_gradient(&mut self, rho: &Array1<f64>) -> Option<Array1<f64>> {
1009        // A barrier gradient is a property of the wrapped criterion; the
1010        // checkpoint layer neither adds nor persists one.
1011        self.inner.soft_rho_guard_gradient(rho)
1012    }
1013
1014    fn criterion_invariant_directions(&mut self, theta: &Array1<f64>) -> Option<Array2<f64>> {
1015        // The invariance is a property of the wrapped criterion's penalty map;
1016        // the checkpoint layer neither adds nor persists one.
1017        self.inner.criterion_invariant_directions(theta)
1018    }
1019
1020    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
1021        // Forward to the wrapped objective, then prime our last-inner-beta
1022        // cache so a subsequent finalize-write encodes the seeded β if no
1023        // eval surfaces a fresher β first. Only prime on actual install —
1024        // `NoSlot` means the inner solver will not see β, so the cache
1025        // entry would be a lie.
1026        // A donated β has no producing ρ at this API boundary. It may seed the
1027        // next evaluation, but it cannot become final-state evidence until an
1028        // evaluation returns it together with the coordinate it was solved at.
1029        self.inner.seed_inner_state(beta)
1030    }
1031
1032    fn terminal_eval_order(&self) -> Option<OuterEvalOrder> {
1033        self.inner.terminal_eval_order()
1034    }
1035
1036    fn owns_terminal_coefficient_mode(&self) -> bool {
1037        // Forward the wrapped objective's ownership: the terminal reset must
1038        // still fire for a cap-less mode owner (e.g. a custom family) when its
1039        // fit routes through a cache session and is wrapped here (#2334).
1040        self.inner.owns_terminal_coefficient_mode()
1041    }
1042
1043    fn reactive_domain_scalar_contract(
1044        &self,
1045    ) -> Result<Option<crate::continuation_path::ContinuationScalarContract>, EstimationError> {
1046        self.inner.reactive_domain_scalar_contract()
1047    }
1048
1049    fn install_reactive_domain_scalar_state(
1050        &mut self,
1051        state: &crate::continuation_path::ContinuationScalarState,
1052    ) -> Result<(), EstimationError> {
1053        self.inner.install_reactive_domain_scalar_state(state)
1054    }
1055
1056    fn begin_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
1057        self.inner.begin_reactive_domain_waypoint()?;
1058        self.reactive_waypoint_active
1059            .store(true, Ordering::Relaxed);
1060        Ok(())
1061    }
1062
1063    fn commit_reactive_domain_waypoint(
1064        &mut self,
1065        rho: &Array1<f64>,
1066    ) -> Result<(), EstimationError> {
1067        let result = self.inner.commit_reactive_domain_waypoint(rho);
1068        self.reactive_waypoint_active
1069            .store(false, Ordering::Relaxed);
1070        result
1071    }
1072
1073    fn rollback_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
1074        let result = self.inner.rollback_reactive_domain_waypoint();
1075        self.reactive_waypoint_active
1076            .store(false, Ordering::Relaxed);
1077        result
1078    }
1079
1080    fn reset(&mut self) {
1081        self.reactive_waypoint_active
1082            .store(false, Ordering::Relaxed);
1083        self.inner.reset();
1084    }
1085
1086    fn begin_exact_polish(&mut self) -> bool {
1087        self.inner.begin_exact_polish()
1088    }
1089}
1090
1091/// Closure-based adapter for [`OuterObjective`].
1092///
1093/// This allows any call site to construct an `OuterObjective` from closures
1094/// without needing to define a wrapper struct or modify the state type.
1095/// Each call site wraps its existing methods into closures and passes them here.
1096pub struct ClosureObjective<
1097    S,
1098    Fc,
1099    Fe,
1100    Fr = fn(&mut S),
1101    Fefs = fn(&mut S, &Array1<f64>) -> Result<EfsEval, EstimationError>,
1102    Feo = fn(&mut S, &Array1<f64>, OuterEvalOrder) -> Result<OuterEval, EstimationError>,
1103    Fsp = fn(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
1104    Fseed = fn(&mut S, &Array1<f64>) -> Result<SeedOutcome, EstimationError>,
1105> {
1106    pub state: S,
1107    pub(crate) cap: OuterCapability,
1108    pub(crate) cost_fn: Fc,
1109    pub(crate) eval_fn: Fe,
1110    /// Optional order-aware eval closure. When `None`, `eval_with_order()`
1111    /// dispatches value-only work to `cost_fn` and derivative-bearing work to
1112    /// `eval_fn`, matching the [`OuterObjective`] default contract.
1113    pub(crate) eval_order_fn: Option<Feo>,
1114    /// Optional reset closure. When `None`, `reset()` is a no-op.
1115    pub(crate) reset_fn: Option<Fr>,
1116    /// Optional EFS evaluation closure. When `None`, the default
1117    /// `OuterObjective::eval_efs` returns an error.
1118    pub(crate) efs_fn: Option<Fefs>,
1119    pub(crate) fixed_point_certificate_fn: Option<
1120        Box<dyn FnMut(&mut S, &Array1<f64>) -> Result<FixedPointCertificateEval, EstimationError>>,
1121    >,
1122    /// Optional single-shot transition from an approximate derivative pilot to
1123    /// the exact objective measure.
1124    pub(crate) exact_polish_fn: Option<Box<dyn FnMut(&mut S) -> bool>>,
1125    /// Optional analytic λ→∞ rail-face limit hook (#2348 Inc 5). Installed by
1126    /// objectives whose criterion has an exact closed-form limit at an
1127    /// infinite-smoothing face; `None` means the outer certificate falls back
1128    /// to measuring the tail.
1129    pub(crate) rail_face_limit_fn: Option<
1130        Box<
1131            dyn FnMut(
1132                &mut S,
1133                &Array1<f64>,
1134                &[usize],
1135            ) -> Result<RailFaceLimitOutcome, EstimationError>,
1136        >,
1137    >,
1138    /// Optional soft rho-guard barrier gradient hook (#2545). Installed by
1139    /// objectives whose criterion carries the unconditional `log cosh` barrier;
1140    /// `None` means "no barrier", and the certificate subtracts nothing.
1141    pub(crate) soft_rho_guard_gradient_fn:
1142        Option<Box<dyn FnMut(&mut S, &Array1<f64>) -> Array1<f64>>>,
1143    /// Optional criterion-invariance hook (#2676). Installed by objectives whose
1144    /// penalty map carries an exact linear redundancy; `None` means "no
1145    /// invariance", and the certificate deflates nothing — the pre-#2676
1146    /// behaviour, bit for bit.
1147    pub(crate) criterion_invariance_fn:
1148        Option<Box<dyn FnMut(&mut S, &Array1<f64>) -> Option<Array2<f64>>>>,
1149    /// Optional seed-screening ranking proxy closure. When `None`,
1150    /// `eval_screening_proxy()` falls back to `eval_cost()` (the trait
1151    /// default), preserving legacy behavior for non-REML objectives.
1152    pub(crate) screening_proxy_fn: Option<Fsp>,
1153    /// Optional inner-state seeding closure. Objectives with PIRLS / Newton
1154    /// inner state install cached β here before the first outer eval.
1155    pub(crate) seed_fn: Option<Fseed>,
1156    /// Analytic evaluator that must install the terminal owned state even when
1157    /// the selected optimization plan itself used EFS.
1158    pub(crate) terminal_eval_order: Option<OuterEvalOrder>,
1159}
1160
1161impl<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed> OuterObjective
1162    for ClosureObjective<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed>
1163where
1164    Fc: FnMut(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
1165    Fe: FnMut(&mut S, &Array1<f64>) -> Result<OuterEval, EstimationError>,
1166    Fr: FnMut(&mut S),
1167    Fefs: FnMut(&mut S, &Array1<f64>) -> Result<EfsEval, EstimationError>,
1168    Feo: FnMut(&mut S, &Array1<f64>, OuterEvalOrder) -> Result<OuterEval, EstimationError>,
1169    Fsp: FnMut(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
1170    Fseed: FnMut(&mut S, &Array1<f64>) -> Result<SeedOutcome, EstimationError>,
1171{
1172    fn capability(&self) -> OuterCapability {
1173        self.cap.clone()
1174    }
1175
1176    fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
1177        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
1178        (self.cost_fn)(&mut self.state, rho)
1179    }
1180
1181    fn eval_screening_proxy(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
1182        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
1183        match self.screening_proxy_fn.as_mut() {
1184            Some(f) => f(&mut self.state, rho),
1185            None => (self.cost_fn)(&mut self.state, rho),
1186        }
1187    }
1188
1189    fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
1190        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
1191        (self.eval_fn)(&mut self.state, rho)
1192    }
1193
1194    fn eval_with_order(
1195        &mut self,
1196        rho: &Array1<f64>,
1197        order: OuterEvalOrder,
1198    ) -> Result<OuterEval, EstimationError> {
1199        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
1200        match self.eval_order_fn.as_mut() {
1201            Some(f) => f(&mut self.state, rho, order),
1202            None => match order {
1203                OuterEvalOrder::Value => {
1204                    let cost = (self.cost_fn)(&mut self.state, rho)?;
1205                    Ok(OuterEval::value_only(cost, rho.len(), None))
1206                }
1207                OuterEvalOrder::ValueAndGradient | OuterEvalOrder::ValueGradientHessian => {
1208                    (self.eval_fn)(&mut self.state, rho)
1209                }
1210            },
1211        }
1212    }
1213
1214    fn eval_efs(&mut self, rho: &Array1<f64>) -> Result<EfsEval, EstimationError> {
1215        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
1216        match self.efs_fn.as_mut() {
1217            Some(f) => f(&mut self.state, rho),
1218            None => Err(EstimationError::RemlOptimizationFailed(
1219                "EFS evaluation not implemented for this objective".to_string(),
1220            )),
1221        }
1222    }
1223
1224    fn eval_fixed_point_certificate(
1225        &mut self,
1226        rho: &Array1<f64>,
1227    ) -> Result<FixedPointCertificateEval, EstimationError> {
1228        crate::estimate::reml::outer_eval::record_current_outer_theta_for_ift(rho);
1229        match self.fixed_point_certificate_fn.as_mut() {
1230            Some(f) => f(&mut self.state, rho),
1231            None => Err(EstimationError::RemlOptimizationFailed(
1232                "fixed-point certification not implemented for this closure objective".to_string(),
1233            )),
1234        }
1235    }
1236
1237    fn rail_face_limit(
1238        &mut self,
1239        rho: &Array1<f64>,
1240        face: &[usize],
1241    ) -> Result<RailFaceLimitOutcome, EstimationError> {
1242        if face.iter().any(|&k| k >= rho.len()) {
1243            return Err(EstimationError::RemlOptimizationFailed(format!(
1244                "rail face {face:?} is outside the rho layout of dimension {}",
1245                rho.len()
1246            )));
1247        }
1248        match self.rail_face_limit_fn.as_mut() {
1249            Some(f) => f(&mut self.state, rho, face),
1250            None => Ok(RailFaceLimitOutcome::OutsideClosedForm {
1251                reason: "this objective does not implement an analytic face limit".to_string(),
1252            }),
1253        }
1254    }
1255
1256    fn soft_rho_guard_gradient(&mut self, theta: &Array1<f64>) -> Option<Array1<f64>> {
1257        // The hook speaks ρ; the seam speaks θ. The barrier acts on ρ only —
1258        // `RemlState::build_prior` adds it to `grad[..k]` and to nothing else —
1259        // so on an objective whose outer coordinate is
1260        // `θ = [ρ (rho_dim), ψ/link (psi_dim)]` the publication must be
1261        // θ-length with EXACT zeros in the trailing block. Doing that
1262        // arithmetic here, from the DECLARED layout, rather than at each
1263        // construction site is what makes the two REML arms install a
1264        // byte-identical hook: standard REML (`psi_dim = 0`) sees the embedding
1265        // collapse to the identity, and the mixture/SAS arm gets the zeros it
1266        // needs without writing a single index (#2629).
1267        //
1268        // Why the layout and not `theta.len()`: a misalignment here is silent.
1269        // Every coordinate's barrier is the same order of magnitude, so
1270        // subtracting one coordinate's from another's is invisible in the norm
1271        // and surfaces only as a coordinate that never certifies.
1272        let layout = self.cap.theta_layout();
1273        if theta.len() != layout.n_params {
1274            log::trace!(
1275                "[#2545/#2629] barrier publication declined: theta length {} is not the \
1276                 declared n_params {} (rho_dim={}, psi_dim={})",
1277                theta.len(),
1278                layout.n_params,
1279                layout.rho_dim(),
1280                layout.psi_dim
1281            );
1282            return None;
1283        }
1284        let rho_dim = layout.rho_dim();
1285        let rho = theta.slice(ndarray::s![..rho_dim]).to_owned();
1286        let guard = self.soft_rho_guard_gradient_fn.as_mut()?(&mut self.state, &rho);
1287        // A hook that answers in the wrong shape is reported as an ABSENCE, not
1288        // spliced in at whatever length it returned: the consumers index this
1289        // array by outer coordinate, and a length mismatch would subtract one
1290        // coordinate's barrier from another's gradient. Reporting `None` costs
1291        // only the pre-#2545 behavior (the barrier stays in the residual).
1292        if guard.len() != rho_dim || !guard.iter().all(|v| v.is_finite()) {
1293            log::trace!(
1294                "[#2545/#2629] barrier publication declined: the hook returned {} entries \
1295                 for a rho block of {rho_dim}, or a non-finite one",
1296                guard.len()
1297            );
1298            return None;
1299        }
1300        if layout.psi_dim == 0 {
1301            return Some(guard);
1302        }
1303        let mut published = Array1::<f64>::zeros(layout.n_params);
1304        published.slice_mut(ndarray::s![..rho_dim]).assign(&guard);
1305        Some(published)
1306    }
1307
1308    fn criterion_invariant_directions(&mut self, theta: &Array1<f64>) -> Option<Array2<f64>> {
1309        // Same seam discipline as the barrier hook above (#2629): the closure
1310        // speaks rho, the certificate speaks theta, and the psi/link block is
1311        // EXACTLY zero because the invariance lives entirely in the penalty
1312        // map. Doing the embedding here, from the declared layout, is what lets
1313        // the standard-REML and the exact-joint spatial arms install a
1314        // byte-identical hook.
1315        let layout = self.cap.theta_layout();
1316        if theta.len() != layout.n_params {
1317            log::trace!(
1318                "[#2676] invariance publication declined: theta length {} is not the declared \
1319                 n_params {} (rho_dim={}, psi_dim={})",
1320                theta.len(),
1321                layout.n_params,
1322                layout.rho_dim(),
1323                layout.psi_dim
1324            );
1325            return None;
1326        }
1327        let rho_dim = layout.rho_dim();
1328        let rho = theta.slice(ndarray::s![..rho_dim]).to_owned();
1329        let directions = self.criterion_invariance_fn.as_mut()?(&mut self.state, &rho)?;
1330        // A hook answering in the wrong shape is reported as an ABSENCE rather
1331        // than spliced in: deflating a direction that is not the criterion's
1332        // invariance would remove real curvature from the certificate's view,
1333        // which is the one failure this whole mechanism must not have.
1334        if directions.nrows() != rho_dim
1335            || directions.ncols() == 0
1336            || !directions.iter().all(|value| value.is_finite())
1337        {
1338            log::trace!(
1339                "[#2676] invariance publication declined: the hook returned a {}x{} block for a \
1340                 rho block of {rho_dim}, or a non-finite one",
1341                directions.nrows(),
1342                directions.ncols(),
1343            );
1344            return None;
1345        }
1346        if layout.psi_dim == 0 {
1347            return Some(directions);
1348        }
1349        let mut published = Array2::<f64>::zeros((layout.n_params, directions.ncols()));
1350        published
1351            .slice_mut(ndarray::s![..rho_dim, ..])
1352            .assign(&directions);
1353        Some(published)
1354    }
1355
1356    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
1357        // Empty β: by convention, "no warm-start available" — treat as a
1358        // no-op install. Distinct from `NoSlot` because the objective may
1359        // very well have a slot; the caller just didn't supply a β to fill
1360        // it. Reporting `Installed` is correct: the slot's pre-existing
1361        // state (cold default) is the post-seed state.
1362        if beta.is_empty() {
1363            return Ok(SeedOutcome::Installed);
1364        }
1365        match self.seed_fn.as_mut() {
1366            Some(f) => f(&mut self.state, beta),
1367            // No hook installed — the objective owns no inner-β slot.
1368            // The caller decides whether this is a loud cache-provenance
1369            // event or a silent continuation-walk degradation.
1370            None => Ok(SeedOutcome::NoSlot),
1371        }
1372    }
1373
1374    fn terminal_eval_order(&self) -> Option<OuterEvalOrder> {
1375        self.terminal_eval_order
1376    }
1377
1378    fn reset(&mut self) {
1379        if let Some(f) = self.reset_fn.as_mut() {
1380            f(&mut self.state);
1381        }
1382    }
1383
1384    fn owns_terminal_coefficient_mode(&self) -> bool {
1385        // A forced terminal eval order is set *precisely* to install this
1386        // objective's owned coefficient mode through one analytic evaluator at
1387        // `rho_star` (see `terminal_eval_order`'s field doc and
1388        // `with_terminal_eval_order`). So `terminal_eval_order.is_some()` is the
1389        // existing, single-source-of-truth marker that this closure objective
1390        // owns a terminal coefficient mode — no separate flag to keep in sync.
1391        // Only the custom-family builder sets it; every other closure objective
1392        // (REML search proxies, reactive fixtures) leaves it `None` and keeps
1393        // the default `false`.
1394        self.terminal_eval_order.is_some()
1395    }
1396
1397    fn begin_exact_polish(&mut self) -> bool {
1398        self.exact_polish_fn
1399            .as_mut()
1400            .is_some_and(|transition| transition(&mut self.state))
1401    }
1402}
1403
1404impl<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed> ClosureObjective<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed> {
1405    pub fn with_exact_polish<Fpolish>(mut self, transition: Fpolish) -> Self
1406    where
1407        Fpolish: FnMut(&mut S) -> bool + 'static,
1408    {
1409        self.exact_polish_fn = Some(Box::new(transition));
1410        self
1411    }
1412
1413    /// Force final state installation through one analytic evaluator order.
1414    /// Search-time solver selection remains unchanged.
1415    pub fn with_terminal_eval_order(mut self, order: OuterEvalOrder) -> Self {
1416        self.terminal_eval_order = Some(order);
1417        self
1418    }
1419
1420    /// Install the analytic λ→∞ rail-face limit hook (#2348 Inc 5).
1421    pub fn with_rail_face_limit<Fface>(mut self, limit: Fface) -> Self
1422    where
1423        Fface: FnMut(
1424                &mut S,
1425                &Array1<f64>,
1426                &[usize],
1427            ) -> Result<RailFaceLimitOutcome, EstimationError>
1428            + 'static,
1429    {
1430        self.rail_face_limit_fn = Some(Box::new(limit));
1431        self
1432    }
1433
1434    /// Install the soft rho-guard barrier gradient hook (#2545).
1435    ///
1436    /// The closure must PROJECT the barrier gradient the criterion already
1437    /// added (for REML: `RemlState::soft_rho_guard_gradient`, which reads the
1438    /// same `SoftRhoGuardPriorAtom` `build_prior` reads), never recompute it
1439    /// from the policy constants — the barrier is evaluated at the
1440    /// weight-anchored coordinate, so a raw-ρ closed form is a different
1441    /// function on any weighted fit.
1442    ///
1443    /// The closure speaks **ρ**, not θ: it receives the leading `rho_dim`
1444    /// entries of the outer point and returns one entry per ρ-coordinate.
1445    /// [`OuterObjective::soft_rho_guard_gradient`] embeds that into the full θ
1446    /// with exact zeros in the ψ/link block, so an objective with auxiliary
1447    /// outer coordinates installs the SAME closure as one without — the layout
1448    /// arithmetic that the mixture/SAS arm would otherwise have had to
1449    /// hand-write (and that #2629 records as invisible when wrong) lives in one
1450    /// place, driven by the declared [`OuterThetaLayout`].
1451    ///
1452    /// A closure whose criterion is NOT built on `RemlState` must not install
1453    /// this hook at all: `None` is the correct answer for an objective that
1454    /// carries no barrier, and publishing a zero array would be indistinguishable
1455    /// from publishing a real one at the consumers.
1456    pub fn with_soft_rho_guard_gradient<Fguard>(mut self, guard: Fguard) -> Self
1457    where
1458        Fguard: FnMut(&mut S, &Array1<f64>) -> Array1<f64> + 'static,
1459    {
1460        self.soft_rho_guard_gradient_fn = Some(Box::new(guard));
1461        self
1462    }
1463
1464    /// Publish the criterion's exact invariance directions (#2676).
1465    ///
1466    /// The closure receives the FULL outer point and returns orthonormal
1467    /// columns in the same coordinates, so an objective with auxiliary `psi` or
1468    /// link coordinates supplies the embedding itself (the rho block is what
1469    /// carries the invariance; every other coordinate is exactly zero).
1470    ///
1471    /// An objective whose criterion is not built on a penalty map must not
1472    /// install this hook: `None` is the correct answer, and publishing an empty
1473    /// matrix would be indistinguishable from publishing a real one.
1474    pub fn with_criterion_invariance<Finv>(mut self, invariance: Finv) -> Self
1475    where
1476        Finv: FnMut(&mut S, &Array1<f64>) -> Option<Array2<f64>> + 'static,
1477    {
1478        self.criterion_invariance_fn = Some(Box::new(invariance));
1479        self
1480    }
1481}
1482
1483impl<S, Fc, Fe, Fr, Fefs, Feo, Fsp> ClosureObjective<S, Fc, Fe, Fr, Fefs, Feo, Fsp>
1484where
1485    Fc: FnMut(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
1486    Fe: FnMut(&mut S, &Array1<f64>) -> Result<OuterEval, EstimationError>,
1487    Fr: FnMut(&mut S),
1488    Fefs: FnMut(&mut S, &Array1<f64>) -> Result<EfsEval, EstimationError>,
1489    Feo: FnMut(&mut S, &Array1<f64>, OuterEvalOrder) -> Result<OuterEval, EstimationError>,
1490    Fsp: FnMut(&mut S, &Array1<f64>) -> Result<f64, EstimationError>,
1491{
1492
1493    pub fn with_seed_inner_state<Fseed>(
1494        self,
1495        seed_fn: Fseed,
1496    ) -> ClosureObjective<S, Fc, Fe, Fr, Fefs, Feo, Fsp, Fseed>
1497    where
1498        Fseed: FnMut(&mut S, &Array1<f64>) -> Result<SeedOutcome, EstimationError>,
1499    {
1500        ClosureObjective {
1501            state: self.state,
1502            cap: self.cap,
1503            cost_fn: self.cost_fn,
1504            eval_fn: self.eval_fn,
1505            eval_order_fn: self.eval_order_fn,
1506            reset_fn: self.reset_fn,
1507            efs_fn: self.efs_fn,
1508            fixed_point_certificate_fn: self.fixed_point_certificate_fn,
1509            exact_polish_fn: self.exact_polish_fn,
1510            rail_face_limit_fn: self.rail_face_limit_fn,
1511            soft_rho_guard_gradient_fn: self.soft_rho_guard_gradient_fn,
1512            criterion_invariance_fn: self.criterion_invariance_fn,
1513            screening_proxy_fn: self.screening_proxy_fn,
1514            seed_fn: Some(seed_fn),
1515            terminal_eval_order: self.terminal_eval_order,
1516        }
1517    }
1518}
1519/// Classify an [`EstimationError`] for the outer objective boundary and
1520/// carry it across as a typed source.
1521///
1522/// # Why this is not a substring match
1523///
1524/// Recoverability is a property of what failed, and only the producer
1525/// knows it. This function used to decide it by testing whether the
1526/// *rendered message* contained a marker string, because the variant it
1527/// received — `CustomFamilyError::UnsupportedConfiguration` — meant "the
1528/// configuration is structurally unsupported" while the condition it
1529/// actually carried was "the inner solve missed its KKT condition at this
1530/// one theta". The marker existed to undo that mismatch after the fact.
1531///
1532/// The consequence was #2553: the same variant was classified RECOVERABLE
1533/// at a call site that could still see its type and FATAL here, where
1534/// only the text was left, so a trial the optimizer was equipped to
1535/// survive aborted the whole fit. A verdict carried in prose is one
1536/// `format!` away from silently changing meaning.
1537///
1538/// Both halves are fixed at their roots. The producer emits
1539/// [`CustomFamilyError::InnerSolveNotConverged`], a variant that *means*
1540/// the trial point is infeasible, and `is_trial_point_infeasible` is an
1541/// exhaustive match over the variants rather than a guess. `opt`'s
1542/// `ObjectiveEvalError` then carries the originating error as a typed
1543/// source, so any later layer that needs the classification downcasts to
1544/// it instead of re-deriving one.
1545pub(crate) fn into_objective_error(context: &str, err: EstimationError) -> ObjectiveEvalError {
1546    let kind = if err.is_trial_point_infeasible() {
1547        ObjectiveEvalKind::Recoverable
1548    } else {
1549        ObjectiveEvalKind::Fatal
1550    };
1551    ObjectiveEvalError::from_source(kind, err).with_context(context)
1552}
1553
1554pub(crate) fn finite_cost_or_error(context: &str, cost: f64) -> Result<f64, ObjectiveEvalError> {
1555    if cost.is_finite() {
1556        Ok(cost)
1557    } else {
1558        Err(ObjectiveEvalError::recoverable(format!(
1559            "{context}: objective returned a non-finite cost"
1560        )))
1561    }
1562}
1563
1564/// Shared first-order validation: gradient length, finite cost, finite gradient.
1565///
1566/// Extracted so the cost+gradient checks live in exactly one place — both the
1567/// full (`finite_outer_eval_or_error`) and first-order
1568/// (`finite_outer_first_order_eval_or_error`) validators delegate here, keeping
1569/// their error messages and check order bit-for-bit identical.
1570fn validate_outer_first_order(
1571    context: &str,
1572    layout: OuterThetaLayout,
1573    eval: &OuterEval,
1574) -> Result<(), ObjectiveEvalError> {
1575    layout.validate_gradient_len(&eval.gradient, context)?;
1576    if !eval.cost.is_finite() {
1577        return Err(ObjectiveEvalError::recoverable(format!(
1578            "{context}: objective returned a non-finite cost"
1579        )));
1580    }
1581    if !eval.gradient.iter().all(|v| v.is_finite()) {
1582        return Err(ObjectiveEvalError::recoverable(format!(
1583            "{context}: objective returned a non-finite gradient"
1584        )));
1585    }
1586    Ok(())
1587}
1588
1589pub(crate) fn finite_outer_eval_or_error(
1590    context: &str,
1591    layout: OuterThetaLayout,
1592    eval: OuterEval,
1593) -> Result<OuterEval, ObjectiveEvalError> {
1594    validate_outer_first_order(context, layout, &eval)?;
1595    match &eval.hessian {
1596        HessianValue::Dense(hessian) => {
1597            layout.validate_hessian_shape(hessian, context)?;
1598            if !hessian.iter().all(|v| v.is_finite()) {
1599                return Err(ObjectiveEvalError::recoverable(format!(
1600                    "{context}: objective returned a non-finite Hessian"
1601                )));
1602            }
1603        }
1604        HessianValue::Operator(op) => {
1605            if op.dim() != layout.n_params {
1606                return Err(ObjectiveEvalError::recoverable(format!(
1607                    "{context}: outer Hessian operator dimension mismatch: got {}, expected {} (rho_dim={}, psi_dim={})",
1608                    op.dim(),
1609                    layout.n_params,
1610                    layout.rho_dim(),
1611                    layout.psi_dim
1612                )));
1613            }
1614        }
1615        HessianValue::Unavailable => {}
1616    }
1617    Ok(eval)
1618}
1619
1620pub(crate) fn finite_outer_first_order_eval_or_error(
1621    context: &str,
1622    layout: OuterThetaLayout,
1623    eval: OuterEval,
1624) -> Result<OuterEval, ObjectiveEvalError> {
1625    validate_outer_first_order(context, layout, &eval)?;
1626    Ok(eval)
1627}
1628
1629pub(crate) fn validate_second_order_seed_hessian(
1630    context: &str,
1631    layout: OuterThetaLayout,
1632    eval: &OuterEval,
1633) -> Result<(), ObjectiveEvalError> {
1634    if layout.n_params > SECOND_ORDER_GEOMETRY_PROBE_MAX_PARAMS || !eval.hessian.is_analytic() {
1635        return Ok(());
1636    }
1637    if matches!(
1638        &eval.hessian,
1639        HessianValue::Operator(op) if !op.materialization().is_available()
1640    ) {
1641        return Ok(());
1642    }
1643
1644    let Some(hessian) = eval.hessian.materialize_dense().map_err(|error| {
1645        ObjectiveEvalError::recoverable(format!(
1646            "{context}: analytic outer Hessian materialization failed during second-order seed validation: {error}"
1647        ))
1648    })?
1649    else {
1650        return Ok(());
1651    };
1652
1653    layout.validate_hessian_shape(&hessian, context)?;
1654    if !hessian.iter().all(|value| value.is_finite()) {
1655        return Err(ObjectiveEvalError::recoverable(format!(
1656            "{context}: analytic outer Hessian probe encountered non-finite entries"
1657        )));
1658    }
1659
1660    Ok(())
1661}
1662
1663// ─── Permutation-invariant outer coordinate canonicalization ──────────
1664//
1665// The additive-term-order (#1539) and tensor-margin-order (#1538) invariance
1666// bugs share one root cause: the outer smoothing-parameter optimizer resolves
1667// a flat double-penalty REML valley differently depending on the ORDER the
1668// penalty blocks are presented (seed placement, multistart, and tie-breaking
1669// all operate in native penalty-index order). The design and penalty are
1670// symmetric up to a block permutation, so the cure is permutation-invariance
1671// by construction: present the optimizer an identical CANONICAL coordinate
1672// layout regardless of native order, then map the optimized ρ back.
1673//
1674// The canonical order is a stable sort of the native coordinates by their
1675// structural key (see `PenaltyCoordinate::canonical_structural_key`), which is
1676// derived purely from each penalty's rotation-/placement-invariant content —
1677// never from its native position. Two formula orders therefore yield the SAME
1678// canonical layout, so the optimizer's seeding/multistart/tie-break all run on
1679// byte-identical coordinates and select identical λ̂.
1680
1681/// Canonical→native index map: `perm[c]` is the native coordinate placed at
1682/// canonical position `c`.
1683///
1684/// Returns `None` when the keys are already in canonical order (the permutation
1685/// is the identity), so the legacy native-order path runs untouched.
1686pub(crate) fn canonical_permutation(keys: &[u64]) -> Option<Vec<usize>> {
1687    let n = keys.len();
1688    if n <= 1 {
1689        return None;
1690    }
1691    let mut perm: Vec<usize> = (0..n).collect();
1692    // Stable sort by structural key. Ties (structurally interchangeable
1693    // coordinates) keep their native relative order — harmless precisely
1694    // because tied coordinates produce identical fits under any assignment.
1695    perm.sort_by_key(|&i| keys[i]);
1696    if perm.iter().enumerate().all(|(c, &i)| c == i) {
1697        None
1698    } else {
1699        Some(perm)
1700    }
1701}
1702
1703/// Reorder a native-layout ρ vector into canonical order: `out[c] = native[perm[c]]`.
1704fn permute_to_canonical(native: &Array1<f64>, perm: &[usize]) -> Array1<f64> {
1705    Array1::from_iter(perm.iter().map(|&i| native[i]))
1706}
1707
1708/// Reorder a canonical-layout ρ vector back into native order:
1709/// `out[perm[c]] = canonical[c]`.
1710fn permute_to_native(canonical: &Array1<f64>, perm: &[usize]) -> Array1<f64> {
1711    let mut out = Array1::zeros(canonical.len());
1712    for (c, &i) in perm.iter().enumerate() {
1713        out[i] = canonical[c];
1714    }
1715    out
1716}
1717
1718/// Map an `OuterResult` produced in CANONICAL coordinate order back to the
1719/// objective's native layout, in place. Permutes every per-coordinate array
1720/// (ρ, gradient, Hessian) consistently; scalar and diagnostic fields are
1721/// untouched.
1722pub(crate) fn outer_result_to_native(mut result: OuterResult, perm: &[usize]) -> OuterResult {
1723    if result.rho.len() == perm.len() {
1724        result.rho = permute_to_native(&result.rho, perm);
1725    }
1726    if let Some(g) = result.final_gradient.as_ref()
1727        && g.len() == perm.len()
1728    {
1729        result.final_gradient = Some(permute_to_native(g, perm));
1730    }
1731    if let Some(h) = result.final_hessian.as_ref()
1732        && h.nrows() == perm.len()
1733        && h.ncols() == perm.len()
1734    {
1735        // H_native[perm[a], perm[b]] = H_canon[a, b].
1736        let mut hn = Array2::<f64>::zeros((perm.len(), perm.len()));
1737        for (a, &ia) in perm.iter().enumerate() {
1738            for (b, &ib) in perm.iter().enumerate() {
1739                hn[[ia, ib]] = h[[a, b]];
1740            }
1741        }
1742        result.final_hessian = Some(hn);
1743    }
1744    result
1745}
1746
1747/// Wraps any [`OuterObjective`] so the optimizer can work in a CANONICAL
1748/// coordinate order while the wrapped objective continues to receive ρ in its
1749/// NATIVE order. The optimizer hands canonical ρ to this wrapper; the wrapper
1750/// permutes canonical→native before forwarding to the inner objective, so the
1751/// inner objective (and any checkpointing/cache layer beneath it) sees native
1752/// ρ exactly as before. Capability shape (`n_params`, `psi_dim`, …) is
1753/// unchanged — only coordinate order differs.
1754pub(crate) struct CanonicalizedObjective<'a> {
1755    inner: &'a mut dyn OuterObjective,
1756    /// Canonical→native map: `perm[c]` is the native index at canonical slot `c`.
1757    perm: Vec<usize>,
1758}
1759
1760impl<'a> CanonicalizedObjective<'a> {
1761    pub(crate) fn new(inner: &'a mut dyn OuterObjective, perm: Vec<usize>) -> Self {
1762        Self { inner, perm }
1763    }
1764
1765    #[inline]
1766    fn to_native(&self, canonical: &Array1<f64>) -> Array1<f64> {
1767        if canonical.len() == self.perm.len() {
1768            permute_to_native(canonical, &self.perm)
1769        } else {
1770            // Defensive: a length the permutation does not cover is forwarded
1771            // verbatim rather than corrupted (should not occur for ρ-coords).
1772            canonical.clone()
1773        }
1774    }
1775
1776    /// Map a native-order eval (gradient/Hessian) back into canonical order so
1777    /// the optimizer sees a self-consistent canonical objective.
1778    fn eval_to_canonical(&self, mut eval: OuterEval) -> OuterEval {
1779        if eval.gradient.len() == self.perm.len() {
1780            eval.gradient = permute_to_canonical(&eval.gradient, &self.perm);
1781        }
1782        eval.hessian = match eval.hessian {
1783            HessianValue::Dense(h)
1784                if h.nrows() == self.perm.len() && h.ncols() == self.perm.len() =>
1785            {
1786                let mut hc = Array2::<f64>::zeros((self.perm.len(), self.perm.len()));
1787                for (a, &ia) in self.perm.iter().enumerate() {
1788                    for (b, &ib) in self.perm.iter().enumerate() {
1789                        hc[[a, b]] = h[[ia, ib]];
1790                    }
1791                }
1792                HessianValue::Dense(hc)
1793            }
1794            other => other,
1795        };
1796        // `inner_beta_hint` is in the coefficient basis (not ρ-coordinate
1797        // order), so it is forwarded unchanged.
1798        eval
1799    }
1800}
1801
1802impl<'a> OuterObjective for CanonicalizedObjective<'a> {
1803    fn capability(&self) -> OuterCapability {
1804        self.inner.capability()
1805    }
1806
1807    fn terminal_eval_order(&self) -> Option<OuterEvalOrder> {
1808        self.inner.terminal_eval_order()
1809    }
1810
1811    fn owns_terminal_coefficient_mode(&self) -> bool {
1812        // Forward through the canonicalizing permutation wrapper so a cap-less
1813        // mode owner (e.g. a custom family) still gets the terminal reset when
1814        // its outer search runs in a non-identity canonical coordinate layout
1815        // (#2334). Ownership is coordinate-order-invariant.
1816        self.inner.owns_terminal_coefficient_mode()
1817    }
1818
1819    fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
1820        let native = self.to_native(rho);
1821        self.inner.eval_cost(&native)
1822    }
1823
1824    fn eval_screening_proxy(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
1825        let native = self.to_native(rho);
1826        self.inner.eval_screening_proxy(&native)
1827    }
1828
1829    fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
1830        let native = self.to_native(rho);
1831        let eval = self.inner.eval(&native)?;
1832        Ok(self.eval_to_canonical(eval))
1833    }
1834
1835    fn eval_with_order(
1836        &mut self,
1837        rho: &Array1<f64>,
1838        order: OuterEvalOrder,
1839    ) -> Result<OuterEval, EstimationError> {
1840        let native = self.to_native(rho);
1841        let eval = self.inner.eval_with_order(&native, order)?;
1842        Ok(self.eval_to_canonical(eval))
1843    }
1844
1845    fn eval_efs(&mut self, rho: &Array1<f64>) -> Result<EfsEval, EstimationError> {
1846        let native = self.to_native(rho);
1847        let mut efs = self.inner.eval_efs(&native)?;
1848        // `steps` has one entry per θ-coordinate (length = n_rho + n_ext). The
1849        // canonical permutation covers only the leading ρ-coordinate block, so
1850        // map exactly those native→canonical; any trailing ψ/ext steps keep
1851        // their position (the canonicalized path is ρ-only, psi_dim == 0).
1852        let m = self.perm.len();
1853        if efs.steps.len() >= m {
1854            let leading = Array1::from_iter(efs.steps.iter().take(m).copied());
1855            let canon_leading = permute_to_canonical(&leading, &self.perm);
1856            for (c, v) in canon_leading.iter().enumerate() {
1857                efs.steps[c] = *v;
1858            }
1859        }
1860        Ok(efs)
1861    }
1862
1863    fn eval_fixed_point_certificate(
1864        &mut self,
1865        rho: &Array1<f64>,
1866    ) -> Result<FixedPointCertificateEval, EstimationError> {
1867        let native = self.to_native(rho);
1868        let mut evaluation = self.inner.eval_fixed_point_certificate(&native)?;
1869        if evaluation.coordinates.len() == self.perm.len() {
1870            evaluation.coordinates = self
1871                .perm
1872                .iter()
1873                .map(|&native_index| evaluation.coordinates[native_index].clone())
1874                .collect();
1875        }
1876        Ok(evaluation)
1877    }
1878
1879    fn rail_face_limit(
1880        &mut self,
1881        rho: &Array1<f64>,
1882        face: &[usize],
1883    ) -> Result<RailFaceLimitOutcome, EstimationError> {
1884        // The face is a set of ρ-coordinates, so it permutes exactly like ρ.
1885        let native_rho = self.to_native(rho);
1886        let mut native_face = Vec::with_capacity(face.len());
1887        for &canonical in face.iter() {
1888            match self.perm.get(canonical).copied() {
1889                Some(native) => native_face.push(native),
1890                None => {
1891                    return Ok(RailFaceLimitOutcome::FaceUnavailable {
1892                        reason: format!(
1893                            "face coordinate {canonical} is outside the canonical permutation"
1894                        ),
1895                    });
1896                }
1897            }
1898        }
1899        let mut limit = match self.inner.rail_face_limit(&native_rho, &native_face)? {
1900            RailFaceLimitOutcome::Available(limit) => limit,
1901            declined => return Ok(declined),
1902        };
1903        // The inner objective reports its face in NATIVE indices (and may have
1904        // reordered it); map back so the certificate names canonical
1905        // coordinates, keeping every per-coordinate array aligned with it.
1906        let mut canonical_of_native = vec![usize::MAX; self.perm.len()];
1907        for (canonical, &native) in self.perm.iter().enumerate() {
1908            canonical_of_native[native] = canonical;
1909        }
1910        let mut canonical_face = Vec::with_capacity(limit.face.len());
1911        for &native in limit.face.iter() {
1912            match canonical_of_native.get(native).copied() {
1913                Some(canonical) if canonical != usize::MAX => canonical_face.push(canonical),
1914                _ => {
1915                    return Ok(RailFaceLimitOutcome::FaceUnavailable {
1916                        reason: format!(
1917                            "the reported face names native coordinate {native}, which the \
1918                             permutation does not cover"
1919                        ),
1920                    });
1921                }
1922            }
1923        }
1924        limit.face = canonical_face;
1925        Ok(RailFaceLimitOutcome::Available(limit))
1926    }
1927
1928    fn soft_rho_guard_gradient(&mut self, rho: &Array1<f64>) -> Option<Array1<f64>> {
1929        // The barrier gradient is one entry per ρ-coordinate, so it permutes
1930        // exactly like `eval`'s gradient does in `eval_to_canonical`. Forgetting
1931        // this permutation would subtract a DIFFERENT coordinate's barrier
1932        // whenever the canonical layout is not the identity — and because every
1933        // coordinate's barrier is the same order of magnitude, the error would
1934        // be invisible in the norm and visible only as a coordinate that never
1935        // certifies.
1936        let native = self.to_native(rho);
1937        let guard = self.inner.soft_rho_guard_gradient(&native)?;
1938        (guard.len() == self.perm.len()).then(|| permute_to_canonical(&guard, &self.perm))
1939    }
1940
1941    fn criterion_invariant_directions(&mut self, rho: &Array1<f64>) -> Option<Array2<f64>> {
1942        // The invariance columns are indexed by rho-coordinate, so their ROWS
1943        // permute exactly like `eval_to_canonical` permutes the gradient. The
1944        // columns index the invariance's own basis and do not permute. Getting
1945        // this wrong would deflate the wrong coordinate pattern — a direction
1946        // the criterion is NOT flat along — and silently hide real curvature.
1947        let native = self.to_native(rho);
1948        let directions = self.inner.criterion_invariant_directions(&native)?;
1949        if directions.nrows() != self.perm.len() {
1950            return None;
1951        }
1952        let mut canonical = Array2::<f64>::zeros(directions.dim());
1953        for (canonical_row, &native_row) in self.perm.iter().enumerate() {
1954            for column in 0..directions.ncols() {
1955                canonical[[canonical_row, column]] = directions[[native_row, column]];
1956            }
1957        }
1958        Some(canonical)
1959    }
1960
1961    fn reset(&mut self) {
1962        self.inner.reset();
1963    }
1964
1965    fn begin_exact_polish(&mut self) -> bool {
1966        self.inner.begin_exact_polish()
1967    }
1968
1969    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
1970        // β is in the coefficient basis, not ρ-coordinate order — forward as-is.
1971        self.inner.seed_inner_state(beta)
1972    }
1973
1974    fn reactive_domain_scalar_contract(
1975        &self,
1976    ) -> Result<Option<crate::continuation_path::ContinuationScalarContract>, EstimationError> {
1977        self.inner.reactive_domain_scalar_contract()
1978    }
1979
1980    fn install_reactive_domain_scalar_state(
1981        &mut self,
1982        state: &crate::continuation_path::ContinuationScalarState,
1983    ) -> Result<(), EstimationError> {
1984        self.inner.install_reactive_domain_scalar_state(state)
1985    }
1986
1987    fn begin_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
1988        self.inner.begin_reactive_domain_waypoint()
1989    }
1990
1991    fn commit_reactive_domain_waypoint(
1992        &mut self,
1993        rho: &Array1<f64>,
1994    ) -> Result<(), EstimationError> {
1995        let native = self.to_native(rho);
1996        self.inner.commit_reactive_domain_waypoint(&native)
1997    }
1998
1999    fn rollback_reactive_domain_waypoint(&mut self) -> Result<(), EstimationError> {
2000        self.inner.rollback_reactive_domain_waypoint()
2001    }
2002
2003    fn accept_seed_without_outer_iterations(
2004        &mut self,
2005        rho: &Array1<f64>,
2006    ) -> Result<Option<f64>, EstimationError> {
2007        let native = self.to_native(rho);
2008        self.inner.accept_seed_without_outer_iterations(&native)
2009    }
2010
2011    fn curvature_homotopy_entry(
2012        &mut self,
2013        rho: &Array1<f64>,
2014    ) -> Option<Result<bool, EstimationError>> {
2015        let native = self.to_native(rho);
2016        self.inner.curvature_homotopy_entry(&native)
2017    }
2018
2019    fn finalize_outer_result(
2020        &mut self,
2021        rho: &Array1<f64>,
2022        plan: &OuterPlan,
2023    ) -> Result<(), EstimationError> {
2024        let native = self.to_native(rho);
2025        self.inner.finalize_outer_result(&native, plan)
2026    }
2027
2028    fn outer_device_admission(&self) -> Option<gam_gpu::policy::RemlOuterAdmission> {
2029        // The device path optimizes in its own coordinate layout; canonicalized
2030        // problems route through the host BFGS/ARC path (where the permutation
2031        // is honored) rather than the device driver.
2032        None
2033    }
2034}
2035
2036#[cfg(test)]
2037mod trial_infeasibility_classification_tests {
2038    use super::*;
2039    use gam_problem::CustomFamilyError;
2040
2041    /// #2553: one variant used to get both verdicts depending on which
2042    /// boundary it crossed, because the boundary read the rendered text.
2043    /// The classification is now a property of the type, so both call
2044    /// sites necessarily agree.
2045    #[test]
2046    fn inner_solve_nonconvergence_is_recoverable_and_carries_its_type() {
2047        let err = EstimationError::CustomFamily(CustomFamilyError::InnerSolveNotConverged {
2048            cycles: 12,
2049            terminal: None,
2050            kkt_residual: Some(4.2e-3),
2051            kkt_tol: Some(1e-8),
2052            theta_dim: 5,
2053            rho_dim: 3,
2054            psi_dim: 2,
2055        });
2056        assert!(err.is_trial_point_infeasible());
2057
2058        let objective_err = into_objective_error("outer fixed-point evaluation", err);
2059        assert!(
2060            objective_err.is_recoverable(),
2061            "an infeasible trial must let the outer search back off, not abort the fit"
2062        );
2063        assert!(
2064            objective_err
2065                .message()
2066                .starts_with("outer fixed-point evaluation: "),
2067            "context must prefix the message: {}",
2068            objective_err.message()
2069        );
2070        // The producer's error is still reachable, so nothing downstream
2071        // has to re-derive the classification from prose.
2072        let source = objective_err
2073            .downcast_ref::<EstimationError>()
2074            .expect("the typed source must survive the boundary");
2075        assert!(matches!(
2076            source,
2077            EstimationError::CustomFamily(CustomFamilyError::InnerSolveNotConverged {
2078                cycles: 12,
2079                ..
2080            })
2081        ));
2082    }
2083
2084    /// The conservative direction: a genuinely structural failure stays
2085    /// fatal. Widening recoverability would let the search grind through a
2086    /// problem that can never work.
2087    #[test]
2088    fn a_structural_configuration_failure_stays_fatal() {
2089        let err = EstimationError::CustomFamily(CustomFamilyError::UnsupportedConfiguration {
2090            reason: "this family does not support the requested link".to_string(),
2091        });
2092        assert!(!err.is_trial_point_infeasible());
2093        assert!(into_objective_error("outer EFS eval", err).is_fatal());
2094    }
2095
2096    /// The two failures render with overlapping text but classify
2097    /// oppositely — precisely what a substring test could not do, and why
2098    /// one existed to be deleted.
2099    #[test]
2100    fn classification_does_not_depend_on_the_rendered_message() {
2101        let infeasible = EstimationError::CustomFamily(CustomFamilyError::InnerSolveNotConverged {
2102            cycles: 1,
2103            terminal: None,
2104            kkt_residual: Some(1.0),
2105            kkt_tol: Some(1e-8),
2106            theta_dim: 1,
2107            rho_dim: 1,
2108            psi_dim: 0,
2109        });
2110        let structural =
2111            EstimationError::CustomFamily(CustomFamilyError::UnsupportedConfiguration {
2112                reason: infeasible.to_string(),
2113            });
2114        // Byte-identical tails, opposite verdicts.
2115        assert!(structural.to_string().contains(&infeasible.to_string()));
2116        assert!(into_objective_error("ctx", infeasible).is_recoverable());
2117        assert!(into_objective_error("ctx", structural).is_fatal());
2118    }
2119}