Skip to main content

gam_solve/reml/
mod.rs

1use self::inner_strategy::GeometryBackendKind;
2use super::*;
3use crate::pirls::PIRLS_CACHE_BYTE_BUDGET;
4use crate::pirls::assemble_and_factor_sparse_penalized_system;
5use gam_linalg::sparse_exact::SparseExactFactor;
6use gam_problem::OuterEval;
7use gam_problem::SasLinkState;
8use gam_terms::basis::LocalDesignJacobianProvider;
9use ndarray::{Array1, Array2, s};
10use std::collections::{HashMap, VecDeque};
11use std::ops::Range;
12use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize};
13use std::sync::{Arc, RwLock};
14
15pub mod assembly;
16pub mod atoms;
17pub mod boundary_laml;
18pub(crate) mod continuation;
19pub(crate) mod eval;
20mod firth;
21pub(super) mod hyper;
22mod inner_strategy;
23// #1521 carve: promoted `pub(crate)` -> `pub` so the extracted
24// `gam-custom-family` crate reaches the Jeffreys-subspace items it consumes.
25pub mod jeffreys_subspace;
26pub mod outer_eval;
27pub mod penalty_logdet;
28pub mod reparameterized_inner;
29pub mod per_atom_efs;
30pub mod reml_outer_engine;
31mod rho_key;
32mod sparse_exact_penalty;
33mod trace;
34
35pub(crate) use sparse_exact_penalty::sparse_penalty_block_count_from_canonical;
36
37pub(crate) const EXACT_TAU_TAU_HESSIAN_DENSE_CACHE_BUDGET_BYTES: usize = 512 * 1024 * 1024;
38pub(crate) const FIRTH_MAX_OBSERVATIONS: usize = 20_000;
39pub(crate) const FIRTH_MAX_COEFFICIENTS: usize = 256;
40pub(crate) const FIRTH_MAX_LINEAR_WORK: usize = 2_000_000;
41pub(crate) const FIRTH_MAX_QUADRATIC_WORK: usize = 100_000_000;
42pub(crate) const PERSISTENT_LATENT_VALUES_CACHE_CAPACITY: usize = 8;
43
44#[derive(Debug)]
45pub(crate) struct PersistentLatentValuesCache {
46    pub(crate) entries: HashMap<String, Array2<f64>>,
47    pub(crate) lru: VecDeque<String>,
48    pub(crate) capacity: usize,
49}
50
51impl Default for PersistentLatentValuesCache {
52    fn default() -> Self {
53        Self {
54            entries: HashMap::new(),
55            lru: VecDeque::new(),
56            capacity: PERSISTENT_LATENT_VALUES_CACHE_CAPACITY,
57        }
58    }
59}
60
61impl PersistentLatentValuesCache {
62    pub(crate) fn lookup(
63        &mut self,
64        key: &str,
65        n_obs: usize,
66        latent_dim: usize,
67    ) -> Option<Array2<f64>> {
68        let values = self.entries.get(key)?;
69        if values.dim() != (n_obs, latent_dim) {
70            return None;
71        }
72        let values = values.clone();
73        self.touch(key.to_string());
74        Some(values)
75    }
76
77    pub(crate) fn insert(&mut self, key: String, values: Array2<f64>) {
78        if values.iter().any(|value| !value.is_finite()) {
79            return;
80        }
81        self.entries.insert(key.clone(), values);
82        self.touch(key);
83        while self.entries.len() > self.capacity {
84            let Some(evicted) = self.lru.pop_front() else {
85                break;
86            };
87            self.entries.remove(&evicted);
88        }
89    }
90
91    pub(crate) fn touch(&mut self, key: String) {
92        if let Some(index) = self.lru.iter().position(|queued| queued == &key) {
93            self.lru.remove(index);
94        }
95        self.lru.push_back(key);
96    }
97}
98
99/// Cached state from the most recent successful PIRLS solve, populated by
100/// `updatewarm_start_from` and consumed by the IFT-based warm-start
101/// predictor (`RemlState::predict_warm_start_beta_ift_with_outcome`).
102/// See the field doc on `RemlState::ift_warm_start_cache` for the math.
103#[derive(Clone)]
104pub(crate) struct IftWarmStartCache {
105    /// β at the converged solve, in ORIGINAL basis. Mirror of
106    /// `warm_start_beta` stashed alongside the H factor for atomic
107    /// consistency under concurrent reads (the predictor needs both
108    /// β and H from the SAME solve; reading them from two locks risks
109    /// a torn pair if a fresh solve lands between reads).
110    pub beta_original: ndarray::Array1<f64>,
111    /// ρ at which the solve occurred. Mirror of `warm_start_rho`,
112    /// stashed for the same atomic-consistency reason.
113    pub rho: ndarray::Array1<f64>,
114    /// Penalized Hessian H_pen at the converged β, in TRANSFORMED basis.
115    /// The IFT predictor factors this on demand; basis transforms run in
116    /// transformed basis for numerical stability.
117    pub penalized_hessian_transformed: gam_linalg::matrix::SymmetricMatrix,
118    /// Reparameterization matrix qs converting between transformed
119    /// (column) basis and original basis: `β_orig = qs · β_tfd`,
120    /// `H_orig = qs · H_tfd · qs^T`.
121    pub qs: ndarray::Array2<f64>,
122    /// True when the PIRLS result was already in original basis
123    /// (`OriginalSparseNative`) — in which case `qs` is the identity
124    /// and the IFT predictor can skip the basis-conversion ops.
125    pub frame_was_original: bool,
126    /// Per-penalty precomputation `S_k · β_cur[cp.col_range]`,
127    /// indexed in lockstep with `RemlObjectiveState::canonical_penalties`.
128    /// Each entry is the local-block mat-vec the IFT predictor would
129    /// otherwise recompute on every predict call. With H_pen factor
130    /// caching (commit ec18559d) the per-call cost dropped from
131    /// `O(p³)` Cholesky to `O(p²) ≈ k · O(block²)` rhs construction;
132    /// at large-scale CTN (p ≈ several thousand) that's several ms
133    /// per predict call still being paid. By stashing `S_k · β_cur`
134    /// at cache-write time the predictor's per-call work drops to
135    /// just the `Δρ_k · e^{ρ_k} · sb_block` accumulation, which is
136    /// `O(p)` rather than `O(p²)`.
137    ///
138    /// `None` when the cache predates this commit's writer hook (e.g.,
139    /// transient state during invalidation); the predictor falls back
140    /// to recomputing the mat-vec when this is `None` or the length
141    /// mismatches `canonical_penalties.len()`.
142    pub lambda_s_beta_blocks: Option<Vec<ndarray::Array1<f64>>>,
143}
144
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
146pub(crate) struct TauTauPlanEstimate {
147    pub(crate) dense_x_bytes: usize,
148    pub(crate) first_order_tau_bytes: usize,
149    pub(crate) second_order_tau_bytes: usize,
150    pub(crate) penalty_first_bytes: usize,
151    pub(crate) penalty_pair_bytes: usize,
152    pub(crate) rho_tau_penalty_bytes: usize,
153    pub(crate) vector_cache_bytes: usize,
154    pub(crate) weighted_scratch_bytes: usize,
155}
156
157impl TauTauPlanEstimate {
158    pub(crate) fn total_bytes(self) -> usize {
159        self.dense_x_bytes
160            .saturating_add(self.first_order_tau_bytes)
161            .saturating_add(self.second_order_tau_bytes)
162            .saturating_add(self.penalty_first_bytes)
163            .saturating_add(self.penalty_pair_bytes)
164            .saturating_add(self.rho_tau_penalty_bytes)
165            .saturating_add(self.vector_cache_bytes)
166            .saturating_add(self.weighted_scratch_bytes)
167    }
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub(crate) struct TauTauHessianPolicy {
172    pub(crate) any_has_implicit: bool,
173    pub(crate) implicit_multidim_duchon: bool,
174    pub(crate) estimated_dense_tau_cache_bytes: usize,
175    pub(crate) gradient_plan: TauTauPlanEstimate,
176    pub(crate) hessian_plan: TauTauPlanEstimate,
177    pub(crate) budget_bytes: usize,
178    pub(crate) firth_pair_terms_unavailable: bool,
179}
180
181impl TauTauHessianPolicy {
182    /// True when the τ-τ exact-Hessian path cannot be assembled at all and the
183    /// eval must fall back to value-and-gradient mode (forcing
184    /// `HessianValue::Unavailable`).
185    ///
186    /// This is the *only* remaining capability gate: the previous
187    /// implementation also forced gradient-only when the design used implicit
188    /// multi-dim Duchon storage or when the dense τ-cache plan would exceed
189    /// the budget.  Both of those are now *cost* gates, not capability gates
190    /// — the unified evaluator's `prefer_outer_hessian_operator(n, p, k)`
191    /// selects the matrix-free `HessianValue::Operator` representation in
192    /// exactly the regimes where the dense cache would be unaffordable, and
193    /// the planner routes operator returns through `run_operator_trust_region`
194    /// (or basis-probes them when `dim ≤ OUTER_HVP_MATERIALIZE_MAX_DIM`).
195    /// Forcing gradient-only would have prevented the operator representation
196    /// from ever being requested, defeating that routing; hence the
197    /// `implicit_multidim_duchon` and cost-bytes clauses are deliberately
198    /// gone.
199    ///
200    /// Firth-pair-terms-unavailability remains a capability gate: when the
201    /// Firth-aware derivative provider cannot produce the τ-τ pair
202    /// corrections at all, no representation choice can substitute.  At every
203    /// production call site this flag is hardcoded `false` (the
204    /// `hphi_tau_tau_partial_apply` + `d_beta_hphi_tau_partial_apply`
205    /// primitives now cover the gap), so this method effectively returns
206    /// `false` in production.  We retain the field and method signature
207    /// unchanged so future Firth corner cases have a single, surfaced place
208    /// to land.
209    pub(crate) fn prefer_gradient_only(self) -> bool {
210        self.firth_pair_terms_unavailable
211    }
212}
213
214pub(crate) fn exact_tau_tau_hessian_policy_with_firth(
215    n_obs: usize,
216    p_coeff: usize,
217    hyper_dirs: &[DirectionalHyperParam],
218    firth_pair_terms_unavailable: bool,
219) -> TauTauHessianPolicy {
220    let f64_bytes = std::mem::size_of::<f64>();
221    let dense_matrix_bytes =
222        |rows: usize, cols: usize| -> usize { rows.saturating_mul(cols).saturating_mul(f64_bytes) };
223    let dense_design_bytes = dense_matrix_bytes(n_obs, p_coeff);
224    let dense_penalty_bytes = dense_matrix_bytes(p_coeff, p_coeff);
225    let psi_dim = hyper_dirs.len();
226    let implicit_n_axes = hyper_dirs
227        .iter()
228        .find_map(DirectionalHyperParam::implicit_axis_count_hint)
229        .unwrap_or(0);
230    let gradient_uses_implicit_design = hyper_dirs
231        .iter()
232        .any(DirectionalHyperParam::has_implicit_operator)
233        && gam_terms::basis::should_use_implicit_operators_with_policy(
234            n_obs,
235            p_coeff,
236            implicit_n_axes,
237            &gam_runtime::resource::ResourcePolicy::default_library(),
238        );
239    let dense_first_order_count = hyper_dirs
240        .iter()
241        .filter(|dir| !dir.has_implicit_operator())
242        .count();
243    let first_penalty_component_count = hyper_dirs
244        .iter()
245        .map(DirectionalHyperParam::penalty_first_component_count)
246        .sum::<usize>();
247
248    let mut dense_second_order_count = 0usize;
249    let mut penalty_pair_count = 0usize;
250    for i in 0..psi_dim {
251        for j in i..psi_dim {
252            if hyper_dirs[i]
253                .x_tau_tau_entry_at(j)
254                .or_else(|| hyper_dirs[j].x_tau_tau_entry_at(i))
255                .is_some_and(|entry| !entry.uses_implicit_storage())
256            {
257                dense_second_order_count += if i == j { 1 } else { 2 };
258            }
259            if hyper_dirs[i].has_penaltysecond_pair_at(j)
260                || hyper_dirs[j].has_penaltysecond_pair_at(i)
261            {
262                penalty_pair_count += if i == j { 1 } else { 2 };
263            }
264        }
265    }
266
267    let gradient_dense_first_order_count = if gradient_uses_implicit_design {
268        dense_first_order_count
269    } else {
270        psi_dim
271    };
272    let gradient_needs_dense_x =
273        firth_pair_terms_unavailable || gradient_dense_first_order_count > 0;
274    let gradient_plan = TauTauPlanEstimate {
275        dense_x_bytes: if gradient_needs_dense_x {
276            dense_design_bytes
277        } else {
278            0
279        },
280        first_order_tau_bytes: if gradient_dense_first_order_count > 0 {
281            dense_design_bytes
282        } else {
283            0
284        },
285        second_order_tau_bytes: 0,
286        penalty_first_bytes: psi_dim.saturating_mul(dense_penalty_bytes),
287        penalty_pair_bytes: 0,
288        rho_tau_penalty_bytes: 0,
289        vector_cache_bytes: n_obs.saturating_mul(f64_bytes),
290        weighted_scratch_bytes: dense_penalty_bytes,
291    };
292    let hessian_plan = TauTauPlanEstimate {
293        dense_x_bytes: if psi_dim > 0 { dense_design_bytes } else { 0 },
294        first_order_tau_bytes: dense_first_order_count.saturating_mul(dense_design_bytes),
295        second_order_tau_bytes: dense_second_order_count.saturating_mul(dense_design_bytes),
296        penalty_first_bytes: psi_dim.saturating_mul(dense_penalty_bytes),
297        penalty_pair_bytes: penalty_pair_count.saturating_mul(dense_penalty_bytes),
298        rho_tau_penalty_bytes: first_penalty_component_count
299            .saturating_mul(2)
300            .saturating_mul(dense_penalty_bytes),
301        vector_cache_bytes: psi_dim.saturating_mul(n_obs).saturating_mul(f64_bytes),
302        weighted_scratch_bytes: dense_penalty_bytes,
303    };
304    let any_has_implicit = hyper_dirs
305        .iter()
306        .any(DirectionalHyperParam::has_implicit_operator);
307    let implicit_multidim_duchon = hyper_dirs
308        .iter()
309        .any(DirectionalHyperParam::has_implicit_multidim_duchon);
310    let estimated_dense_tau_cache_bytes = hessian_plan
311        .first_order_tau_bytes
312        .saturating_add(hessian_plan.second_order_tau_bytes);
313    TauTauHessianPolicy {
314        any_has_implicit,
315        implicit_multidim_duchon,
316        estimated_dense_tau_cache_bytes,
317        gradient_plan,
318        hessian_plan,
319        budget_bytes: EXACT_TAU_TAU_HESSIAN_DENSE_CACHE_BUDGET_BYTES,
320        firth_pair_terms_unavailable: firth_pair_terms_unavailable && !hyper_dirs.is_empty(),
321    }
322}
323
324pub(crate) fn firth_problem_scale_allows(n_obs: usize, p_coeff: usize) -> bool {
325    let linear_work = n_obs.saturating_mul(p_coeff);
326    let quadratic_work = linear_work.saturating_mul(p_coeff);
327    n_obs <= FIRTH_MAX_OBSERVATIONS
328        && p_coeff <= FIRTH_MAX_COEFFICIENTS
329        && linear_work <= FIRTH_MAX_LINEAR_WORK
330        && quadratic_work <= FIRTH_MAX_QUADRATIC_WORK
331}
332
333#[cfg(test)]
334mod tests {
335    use super::atoms::CriterionAtom;
336    use super::{
337        DirectionalHyperParam, EvalCacheManager, EvalShared, HyperDesignDerivative,
338        HyperPenaltyDerivative, ImplicitDerivLevel, RemlConfig, RemlState,
339    };
340    use crate::estimate::EstimationError;
341    use crate::pirls::PirlsCoordinateFrame;
342    use faer::Side;
343    use gam_linalg::faer_ndarray::FaerCholesky;
344    use gam_linalg::matrix::symmetrize_in_place;
345    use gam_problem::{
346        GlmLikelihoodSpec, InverseLink, LikelihoodSpec, ResponseFamily, StandardLink,
347    };
348    use gam_problem::{HessianValue, OuterEval};
349    use gam_terms::basis::{ImplicitDesignPsiDerivative, RadialScalarKind};
350    use ndarray::{Array1, Array2, array, s};
351    use std::sync::Arc;
352
353    /// Shorthand for the canonical Binomial-Logit `GlmLikelihoodSpec` used by
354    /// the REML test fixtures.
355    pub(crate) fn binomial_logit_glm_spec() -> GlmLikelihoodSpec {
356        GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
357            ResponseFamily::Binomial,
358            InverseLink::Standard(StandardLink::Logit),
359        ))
360    }
361
362    /// Shorthand for the canonical Gaussian-Identity `GlmLikelihoodSpec` used
363    /// by the REML test fixtures.
364    pub(crate) fn gaussian_identity_glm_spec() -> GlmLikelihoodSpec {
365        GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
366            ResponseFamily::Gaussian,
367            InverseLink::Standard(StandardLink::Identity),
368        ))
369    }
370
371    impl DirectionalHyperParam {
372        pub(super) fn new(
373            x_tau_original: Array2<f64>,
374            penalty_first_components: Vec<(usize, Array2<f64>)>,
375            x_tau_tau_original: Option<Vec<Option<Array2<f64>>>>,
376            penaltysecond_components: Option<Vec<Option<Vec<(usize, Array2<f64>)>>>>,
377        ) -> Result<Self, EstimationError> {
378            let x_tau_tau_original = x_tau_tau_original.map(|rows| {
379                rows.into_iter()
380                    .map(|entry| entry.map(HyperDesignDerivative::from))
381                    .collect::<Vec<_>>()
382            });
383            let penalty_first_components = penalty_first_components
384                .into_iter()
385                .map(|(idx, matrix)| (idx, HyperPenaltyDerivative::from(matrix)))
386                .collect();
387            let penaltysecond_components = penaltysecond_components.map(|rows| {
388                rows.into_iter()
389                    .map(|row| {
390                        row.map(|components| {
391                            components
392                                .into_iter()
393                                .map(|(idx, matrix)| (idx, HyperPenaltyDerivative::from(matrix)))
394                                .collect::<Vec<_>>()
395                        })
396                    })
397                    .collect::<Vec<_>>()
398            });
399            Self::new_compact(
400                HyperDesignDerivative::from(x_tau_original),
401                penalty_first_components,
402                x_tau_tau_original,
403                penaltysecond_components,
404            )
405        }
406
407        pub(super) fn single_penalty(
408            penalty_index: usize,
409            x_tau_original: Array2<f64>,
410            s_tau_original: Array2<f64>,
411            x_tau_tau_original: Option<Vec<Option<Array2<f64>>>>,
412            s_tau_tau_original: Option<Vec<Option<Array2<f64>>>>,
413        ) -> Result<Self, EstimationError> {
414            let penaltysecond_components = s_tau_tau_original.map(|rows| {
415                rows.into_iter()
416                    .map(|mat| mat.map(|mat| vec![(penalty_index, mat)]))
417                    .collect::<Vec<_>>()
418            });
419            Self::new(
420                x_tau_original,
421                vec![(penalty_index, s_tau_original)],
422                x_tau_tau_original,
423                penaltysecond_components,
424            )
425        }
426    }
427
428    #[test]
429    pub(crate) fn firth_problem_scale_gate_blocks_large_quadratic_work() {
430        assert!(super::firth_problem_scale_allows(2_000, 200));
431        assert!(!super::firth_problem_scale_allows(4_800, 241));
432        assert!(!super::firth_problem_scale_allows(4_800, 433));
433    }
434
435    #[test]
436    pub(crate) fn tau_tau_hessian_policy_prefers_gradient_only_for_implicit_tau() {
437        let operator = ImplicitDesignPsiDerivative::new(
438            array![1.0, 2.0, 3.0, 4.0],
439            array![0.5, -1.0, 1.5, 2.0],
440            array![0.1, 0.2, 0.3, 0.4],
441            array![[1.0, 0.2], [0.5, 0.1], [1.5, 0.3], [2.0, 0.4]],
442            None,
443            None,
444            2,
445            2,
446            1,
447            2,
448        );
449        let dir = DirectionalHyperParam::new_compact(
450            HyperDesignDerivative::from_implicit(
451                Arc::new(operator),
452                ImplicitDerivLevel::First(0),
453                1..4,
454                5,
455            ),
456            Vec::new(),
457            None,
458            None,
459        )
460        .expect("implicit directional hyperparam");
461        let policy = super::exact_tau_tau_hessian_policy_with_firth(10, 5, &[dir], false);
462        assert!(policy.any_has_implicit);
463        assert_eq!(
464            policy.gradient_plan.dense_x_bytes,
465            10 * 5 * std::mem::size_of::<f64>()
466        );
467        assert!(!policy.prefer_gradient_only());
468    }
469
470    #[test]
471    pub(crate) fn tau_tau_hessian_policy_does_not_force_gradient_only_for_implicit_multidim_duchon()
472    {
473        // Multi-dim Duchon implicit storage used to force gradient-only,
474        // because the τ-cache materialization plan was infeasible.  The
475        // unified evaluator now elects the matrix-free
476        // `HessianValue::Operator` representation in this regime via
477        // `prefer_outer_hessian_operator`, so the planner can route to the
478        // operator trust-region (or basis-probe to dense for small K) — the
479        // capability is preserved and gradient-only must NOT engage.
480        let operator = ImplicitDesignPsiDerivative::new_streaming(
481            Arc::new(array![[0.0, 0.0], [1.0, 0.2]]),
482            Arc::new(array![[0.0, 0.0], [1.0, 1.0]]),
483            vec![0.0, 0.0],
484            RadialScalarKind::PureDuchon {
485                block_order: 1,
486                p_order: 0,
487                s_order: 0,
488                dim: 2,
489            },
490            None,
491            None,
492            0,
493        );
494        let dir = DirectionalHyperParam::new_compact(
495            HyperDesignDerivative::from_implicit(
496                Arc::new(operator),
497                ImplicitDerivLevel::First(0),
498                0..2,
499                2,
500            ),
501            Vec::new(),
502            None,
503            None,
504        )
505        .expect("implicit duchon directional hyperparam");
506        let policy = super::exact_tau_tau_hessian_policy_with_firth(10, 5, &[dir], false);
507        assert!(policy.any_has_implicit);
508        assert!(policy.implicit_multidim_duchon);
509        assert!(!policy.prefer_gradient_only());
510    }
511
512    #[test]
513    pub(crate) fn tau_tau_hessian_policy_does_not_force_gradient_only_when_cache_budget_is_exceeded()
514     {
515        // The dense τ-cache plan exceeds the budget, but cost is no longer a
516        // capability gate: the eval-side selects the matrix-free operator
517        // representation in exactly this regime, and the planner routes
518        // accordingly.  `prefer_gradient_only` must NOT force `Unavailable`
519        // here.
520        let dirs = (0..16)
521            .map(|_| {
522                DirectionalHyperParam::new_compact(
523                    HyperDesignDerivative::from(Array2::<f64>::zeros((2, 2))),
524                    Vec::new(),
525                    None,
526                    None,
527                )
528                .expect("dense directional hyperparam")
529            })
530            .collect::<Vec<_>>();
531        let policy = super::exact_tau_tau_hessian_policy_with_firth(320_000, 71, &dirs, false);
532        assert!(!policy.any_has_implicit);
533        assert!(policy.hessian_plan.total_bytes() > policy.budget_bytes);
534        assert!(policy.hessian_plan.total_bytes() > policy.gradient_plan.total_bytes());
535        assert!(!policy.prefer_gradient_only());
536    }
537
538    #[test]
539    pub(crate) fn tau_tau_hessian_policy_prefers_gradient_only_for_firth_pair_gap() {
540        let dir = DirectionalHyperParam::new_compact(
541            HyperDesignDerivative::from(Array2::<f64>::zeros((2, 2))),
542            Vec::new(),
543            None,
544            None,
545        )
546        .expect("dense directional hyperparam");
547        let policy = super::exact_tau_tau_hessian_policy_with_firth(10, 5, &[dir], true);
548        assert!(policy.firth_pair_terms_unavailable);
549        assert!(policy.prefer_gradient_only());
550    }
551
552    /// Common shape for the design-motion + penalty-motion REML test fixtures
553    /// (Gaussian-identity and binomial-logit at present): both carry the
554    /// same `(y, w, X, S0, cfg, ρ)` plus a perturbation pair, and need the
555    /// same three helpers (`state`, `state_perturbed`, `fd_directional_gradient`).
556    /// Per-fixture `new()` constructors fill the fields with family-specific
557    /// data; the helpers below are shared via default impls so every fixture
558    /// pays the boilerplate once.
559    trait LogitDesignMotionFixture {
560        fn y(&self) -> &Array1<f64>;
561        fn w(&self) -> &Array1<f64>;
562        fn x(&self) -> &Array2<f64>;
563        fn s0(&self) -> &Array2<f64>;
564        fn cfg(&self) -> &RemlConfig;
565        fn rho(&self) -> &Array1<f64>;
566
567        fn state(&self) -> RemlState<'_> {
568            build_logit_state(self.y(), self.w(), self.x(), self.s0(), self.cfg())
569        }
570
571        fn state_perturbed(
572            &self,
573            x_tau: &Array2<f64>,
574            s_tau: &Array2<f64>,
575            eps: f64,
576        ) -> (RemlState<'_>, RemlState<'_>) {
577            let x_plus = self.x() + &x_tau.mapv(|v| eps * v);
578            let x_minus = self.x() - &x_tau.mapv(|v| eps * v);
579            let s_plus = self.s0() + &s_tau.mapv(|v| eps * v);
580            let s_minus = self.s0() - &s_tau.mapv(|v| eps * v);
581            (
582                build_logit_state(self.y(), self.w(), &x_plus, &s_plus, self.cfg()),
583                build_logit_state(self.y(), self.w(), &x_minus, &s_minus, self.cfg()),
584            )
585        }
586
587        /// Central FD approximation to the directional cost derivative at ρ.
588        fn fd_directional_gradient(&self, x_tau: &Array2<f64>, s_tau: &Array2<f64>) -> f64 {
589            let h = 2e-5;
590            let (state_plus, state_minus) = self.state_perturbed(x_tau, s_tau, h);
591            let v_plus = state_plus.compute_cost(self.rho()).expect("cost+");
592            let v_minus = state_minus.compute_cost(self.rho()).expect("cost-");
593            (v_plus - v_minus) / (2.0 * h)
594        }
595    }
596
597    pub(crate) fn build_logit_state<'a>(
598        y: &'a Array1<f64>,
599        w: &'a Array1<f64>,
600        x: &Array2<f64>,
601        s: &Array2<f64>,
602        cfg: &'a RemlConfig,
603    ) -> RemlState<'a> {
604        use crate::estimate::PenaltySpec;
605        let p = x.ncols();
606        let offset = Array1::<f64>::zeros(y.len());
607        let spec = PenaltySpec::Dense(s.clone());
608        let canonical =
609            gam_terms::construction::canonicalize_penalty_specs(&[spec], &[1], p, "test")
610                .map(|(canonical, _)| canonical)
611                .expect("canonicalize");
612        RemlState::newwith_offset(
613            y.view(),
614            x.clone(),
615            w.view(),
616            offset.view(),
617            canonical,
618            p,
619            cfg,
620            Some(vec![1]),
621            None,
622            None,
623        )
624        .expect("state")
625    }
626
627    fn bundle_with_inner_kkt_residual(bundle: &EvalShared, residual: Array1<f64>) -> EvalShared {
628        let mut pirls_result = bundle.pirls_result.as_ref().clone();
629        pirls_result.lastgradient_norm = residual.dot(&residual).sqrt();
630        pirls_result.penalized_gradient_transformed = residual;
631        let mut cloned = bundle.clone();
632        cloned.pirls_result = Arc::new(pirls_result);
633        cloned
634    }
635
636    fn evaluate_synthetic_psi_value_without_inner_kkt(
637        state: &RemlState<'_>,
638        rho: &Array1<f64>,
639        bundle: &EvalShared,
640    ) -> f64 {
641        let mode = super::reml_outer_engine::EvalMode::ValueOnly;
642        let mut assembly = state
643            .build_auto_assembly(rho, bundle, mode, true, false)
644            .expect("uncorrected synthetic-psi assembly");
645        assert!(
646            matches!(
647                &assembly.dispersion,
648                super::reml_outer_engine::DispersionHandling::Fixed { .. }
649            ),
650            "the #2305 bridge fixture must exercise the fixed-dispersion LAML identity"
651        );
652        let p_dim = assembly.beta.len();
653        assembly.ext_coords = vec![super::reml_outer_engine::HyperCoord {
654            a: 0.0,
655            g: Array1::zeros(p_dim),
656            drift: super::reml_outer_engine::HyperCoordDrift::none(),
657            ld_s: 0.0,
658            b_depends_on_beta: false,
659            is_penalty_like: false,
660            firth_g: None,
661            tk_eta_fixed: None,
662            tk_x_fixed: None,
663        }];
664        state
665            .assemble_and_evaluate(rho, bundle, mode, assembly)
666            .expect("uncorrected synthetic-psi value")
667            .cost
668    }
669
670    #[test]
671    fn psi_value_bridge_corrects_nonstationary_inner_mode_2305() {
672        // The residual correction implemented by the unified evaluator is the
673        // fixed-dispersion LAML identity. Gaussian-identity uses profiled-scale
674        // REML and deliberately does not enter that gate, so use a genuine
675        // fixed-dispersion design-moving model here.
676        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
677        let w = Array1::<f64>::ones(y.len());
678        let x = array![
679            [1.0, -1.0, 0.2],
680            [1.0, -0.6, -0.4],
681            [1.0, -0.2, 0.7],
682            [1.0, 0.3, -0.5],
683            [1.0, 0.8, 0.1],
684            [1.0, 1.2, 0.6],
685        ];
686        let s = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.15], [0.0, 0.15, 0.8]];
687        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-12, false);
688        let state = build_logit_state(&y, &w, &x, &s, &cfg);
689        let rho = array![0.2];
690        let base = state.obtain_eval_bundle(&rho).expect("base inner mode");
691        let residual = array![0.18, -0.11, 0.07];
692        let capped = bundle_with_inner_kkt_residual(&base, residual);
693
694        let corrected = state
695            .evaluate_unified_value_only_with_synthetic_ext_count(&rho, &capped, 1, false)
696            .expect("psi value with inner-KKT correction");
697        let exact_kkt_assumption =
698            evaluate_synthetic_psi_value_without_inner_kkt(&state, &rho, &capped);
699        let residual_energy = corrected
700            .ift_residual_energy
701            .expect("design-moving bridge must attach the nonstationary KKT residual");
702
703        assert!(
704            residual_energy.abs() > 1e-10,
705            "the synthetic nonstationary residual must produce a material fixed-dispersion \
706             IFT correction, got residual_energy={residual_energy:.12e}"
707        );
708        assert_eq!(
709            corrected.cost,
710            exact_kkt_assumption - residual_energy,
711            "the psi value bridge must apply exactly the correction reported by the unified \
712             evaluator: corrected={:.12e}, exact-kkt={exact_kkt_assumption:.12e}, \
713             residual-energy={residual_energy:.12e}",
714            corrected.cost,
715        );
716    }
717
718    #[test]
719    fn psi_value_bridge_correction_vanishes_at_stationarity_2305() {
720        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
721        let w = Array1::<f64>::ones(y.len());
722        let x = array![
723            [1.0, -1.0, 0.2],
724            [1.0, -0.6, -0.4],
725            [1.0, -0.2, 0.7],
726            [1.0, 0.3, -0.5],
727            [1.0, 0.8, 0.1],
728            [1.0, 1.2, 0.6],
729        ];
730        let s = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.15], [0.0, 0.15, 0.8]];
731        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-12, false);
732        let state = build_logit_state(&y, &w, &x, &s, &cfg);
733        let rho = array![0.2];
734        let base = state.obtain_eval_bundle(&rho).expect("base inner mode");
735        let stationary = bundle_with_inner_kkt_residual(&base, Array1::zeros(x.ncols()));
736
737        let corrected = state
738            .evaluate_unified_value_only_with_synthetic_ext_count(&rho, &stationary, 1, false)
739            .expect("stationary psi value with correction enabled");
740        let exact_kkt_assumption =
741            evaluate_synthetic_psi_value_without_inner_kkt(&state, &rho, &stationary);
742
743        assert_eq!(
744            corrected.ift_residual_energy,
745            Some(0.0),
746            "the design-moving bridge must attach the residual, whose correction is exactly \
747             zero at stationarity"
748        );
749        assert_eq!(
750            corrected.cost, exact_kkt_assumption,
751            "the generic inner-KKT correction must be exactly zero at a stationary inner mode"
752        );
753    }
754
755    #[test]
756    fn repeated_penalty_ranges_keep_analytic_outer_hessian() {
757        let y = array![0.2, -0.1, 0.3, 0.0];
758        let w = Array1::<f64>::ones(y.len());
759        let x = array![[1.0, -0.7], [1.0, -0.2], [1.0, 0.3], [1.0, 0.9]];
760        let offset = Array1::<f64>::zeros(y.len());
761        let cfg = RemlConfig::external(gaussian_identity_glm_spec(), 1e-10, false);
762        let p = x.ncols();
763        let canonical = vec![
764            gam_terms::construction::CanonicalPenalty::from_dense_root(array![[0.0, 1.0]], p),
765            gam_terms::construction::CanonicalPenalty::from_dense_root(array![[1.0, 0.0]], p),
766        ];
767        let state = RemlState::newwith_offset(
768            y.view(),
769            x,
770            w.view(),
771            offset.view(),
772            canonical,
773            p,
774            &cfg,
775            Some(vec![1, 1]),
776            None,
777            None,
778        )
779        .expect("state");
780
781        assert!(
782            state.analytic_outer_hessian_enabled(),
783            "double-penalty-style repeated coefficient ranges must still route to exact Hessian"
784        );
785    }
786
787    /// #2379: the Gaussian profiled-diagonal seed helper honors its (validated)
788    /// ρ-box by CLAMPING into it — never silently swapping or escaping it. The
789    /// helper now takes an `OrderedRhoBounds`, so an inverted box is impossible
790    /// to hand it (refused upstream at construction); this pins that a valid box
791    /// with the upper bound below the natural profiled optimum clamps the emitted
792    /// seed to that upper bound rather than producing an out-of-box value.
793    #[test]
794    fn gaussian_profiled_diagonal_seed_clamps_into_its_validated_box() {
795        // A smooth-ish Gaussian-identity design whose profiled REML optimum for
796        // the summed penalty sits at a moderate, finite ρ.
797        let n = 40usize;
798        let y = Array1::from_iter((0..n).map(|i| {
799            let t = (i as f64 + 0.5) / n as f64;
800            (std::f64::consts::TAU * t).sin() + 0.05 * (i as f64 % 3.0 - 1.0)
801        }));
802        let w = Array1::<f64>::ones(n);
803        let mut x = Array2::<f64>::zeros((n, 3));
804        for i in 0..n {
805            let t = (i as f64 + 0.5) / n as f64;
806            x[[i, 0]] = 1.0;
807            x[[i, 1]] = t;
808            x[[i, 2]] = t * t;
809        }
810        let offset = Array1::<f64>::zeros(n);
811        let cfg = RemlConfig::external(gaussian_identity_glm_spec(), 1e-10, false);
812        let p = x.ncols();
813        let canonical = vec![gam_terms::construction::CanonicalPenalty::from_dense_root(
814            array![[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
815            p,
816        )];
817        let state = RemlState::newwith_offset(
818            y.view(),
819            x,
820            w.view(),
821            offset.view(),
822            canonical,
823            p,
824            &cfg,
825            Some(vec![1]),
826            None,
827            None,
828        )
829        .expect("state");
830
831        // A wide, ordered box: the emitted seed is finite and inside it. Its
832        // value is the natural profiled ρ (nothing binds).
833        let wide = gam_problem::OrderedRhoBounds::new(-12.0, 12.0).unwrap();
834        let seed_wide = state
835            .analytic_gaussian_profiled_diagonal_rho(wide)
836            .expect("no error")
837            .expect("gaussian-identity profiled diagonal returns a seed");
838        let natural = seed_wide[0];
839        for &r in seed_wide.iter() {
840            assert!(r.is_finite(), "seed coordinate is finite");
841            assert!(
842                (-12.0..=12.0).contains(&r),
843                "seed {r} stays inside the wide box"
844            );
845        }
846
847        // A box whose UPPER bound is pinned strictly below the natural optimum:
848        // the profiled ρ must be clamped DOWN to that upper bound, proving the box
849        // is honored (a silent swap would instead have solved a different box).
850        // Derive the cap from the measured optimum so the assertion is robust to
851        // the exact fixture geometry; `cap_lo < cap_hi` and both are finite.
852        let cap_hi = natural - 2.0;
853        let cap_lo = natural - 10.0;
854        let capped = gam_problem::OrderedRhoBounds::new(cap_lo, cap_hi).unwrap();
855        let seed_capped = state
856            .analytic_gaussian_profiled_diagonal_rho(capped)
857            .expect("no error")
858            .expect("seed present");
859        assert!(
860            seed_capped.iter().all(|&r| (r - cap_hi).abs() < 1e-9),
861            "capped seed {seed_capped:?} clamps to the binding upper bound {cap_hi}"
862        );
863    }
864
865    #[test]
866    fn canonical_logit_firth_declines_exact_tk_hessian_when_row_pair_work_is_large() {
867        let n = 2_000usize;
868        let p = 28usize;
869        let y = Array1::from_iter((0..n).map(|i| if i % 3 == 0 { 1.0 } else { 0.0 }));
870        let w = Array1::<f64>::ones(n);
871        let mut x = Array2::<f64>::zeros((n, p));
872        for i in 0..n {
873            let t = (i as f64 + 0.5) / n as f64;
874            x[[i, 0]] = 1.0;
875            for j in 1..p {
876                x[[i, j]] = ((j as f64) * std::f64::consts::TAU * t).sin()
877                    + 0.25 * (((j + 1) as f64) * std::f64::consts::TAU * t).cos();
878            }
879        }
880        let mut s = Array2::<f64>::zeros((p, p));
881        for j in 1..p {
882            s[[j, j]] = 1.0;
883        }
884        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, true);
885        let state = build_logit_state(&y, &w, &x, &s, &cfg);
886
887        assert!(
888            !RemlState::firth_tk_exact_hessian_scale_allows(n, p),
889            "fixture must sit beyond the O(n²·p) exact-Hessian budget"
890        );
891        assert!(
892            !state.analytic_outer_hessian_enabled(),
893            "large canonical-logit Firth fits should keep exact value/gradient but route outer curvature to BFGS"
894        );
895    }
896
897    #[test]
898    fn canonical_logit_firth_keeps_exact_tk_hessian_for_small_separation_guards() {
899        let n = 40usize;
900        let p = 6usize;
901        let y = Array1::from_iter((0..n).map(|i| if i >= n / 2 { 1.0 } else { 0.0 }));
902        let w = Array1::<f64>::ones(n);
903        let mut x = Array2::<f64>::zeros((n, p));
904        for i in 0..n {
905            let t = (i as f64) / (n - 1) as f64;
906            x[[i, 0]] = 1.0;
907            for j in 1..p {
908                x[[i, j]] = t.powi(j as i32);
909            }
910        }
911        let mut s = Array2::<f64>::zeros((p, p));
912        for j in 1..p {
913            s[[j, j]] = 1.0;
914        }
915        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, true);
916        let state = build_logit_state(&y, &w, &x, &s, &cfg);
917
918        assert!(RemlState::firth_tk_exact_hessian_scale_allows(n, p));
919        assert!(
920            state.analytic_outer_hessian_enabled(),
921            "small Firth rescue fits should keep exact TK Hessian curvature"
922        );
923    }
924
925    #[test]
926    fn nonlogit_firth_keeps_tk_value_and_gradient() {
927        let y = array![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
928        let w = Array1::<f64>::ones(y.len());
929        let x = array![
930            [1.0, -1.0, 0.3],
931            [1.0, -0.7, -0.2],
932            [1.0, -0.3, 0.4],
933            [1.0, 0.0, -0.5],
934            [1.0, 0.2, 0.6],
935            [1.0, 0.6, -0.4],
936            [1.0, 0.9, 0.2],
937            [1.0, 1.3, -0.1],
938        ];
939        let s = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.1], [0.0, 0.1, 0.7]];
940        let rho = array![0.15];
941
942        for link in [StandardLink::Probit, StandardLink::CLogLog] {
943            let likelihood = GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
944                ResponseFamily::Binomial,
945                InverseLink::Standard(link),
946            ));
947            let cfg = RemlConfig::external(likelihood, 1e-9, true).with_max_iterations(500);
948            let state = build_logit_state(&y, &w, &x, &s, &cfg);
949            assert!(
950                !state.analytic_outer_hessian_enabled(),
951                "{link:?} should use BFGS curvature until exact f_obs is available"
952            );
953
954            let bundle = state
955                .obtain_eval_bundle(&rho)
956                .expect("non-logit Firth bundle");
957            let atom = state
958                .tierney_kadane_terms(
959                    &rho,
960                    &bundle,
961                    super::reml_outer_engine::EvalMode::ValueAndGradient,
962                    &[],
963                )
964                .expect("non-logit TK correction");
965            let value = CriterionAtom::value(&atom);
966            let gradient = atom.gradient().expect("TK gradient");
967            assert!(
968                value.is_finite() && value.abs() > 1e-12,
969                "{link:?} must receive a material finite TK correction, got {value}"
970            );
971            assert_eq!(gradient.len(), rho.len());
972            assert!(
973                gradient.iter().all(|entry| entry.is_finite()),
974                "{link:?} TK gradient must be finite: {gradient:?}"
975            );
976        }
977    }
978
979    pub(crate) fn poisson_log_glm_spec() -> GlmLikelihoodSpec {
980        GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
981            ResponseFamily::Poisson,
982            InverseLink::Standard(StandardLink::Log),
983        ))
984    }
985
986    /// Regression (issue #893): for a fixed-dispersion family a uniform prior
987    /// weight `w = c` is *exact* `c`-fold row replication. The two encodings must
988    /// therefore present a byte-identical LAML smoothing-selection surface — both
989    /// the cost `V(ρ)` and its gradient `∇V(ρ)` — because every term (penalised
990    /// deviance `D_p`, the working cross-product `XᵀWX`, the log-determinants)
991    /// is a sum of per-observation contributions that is identical whether a row
992    /// carries weight `c` or is stacked `c` times. This locks the *surface*
993    /// invariant that #893 ultimately reduces to: when the surfaces coincide,
994    /// the only remaining requirement for `λ̂(w=c) = λ̂(c×)` is that the outer
995    /// optimiser resolve the shared optimum (handled by the tightened outer
996    /// tolerance in `workflow.rs`). A regression that reintroduced a
997    /// row-count-vs-weight-sum asymmetry into the inner solve or the cost would
998    /// break this directly, independent of the optimiser tolerance.
999    #[test]
1000    pub(crate) fn fixed_dispersion_laml_surface_is_replication_invariant() {
1001        let n = 200usize;
1002        let p = 8usize;
1003        let c = 3usize;
1004        let mut x = Array2::<f64>::zeros((n, p));
1005        let mut y = Array1::<f64>::zeros(n);
1006        for i in 0..n {
1007            let t = (i as f64) / ((n - 1) as f64);
1008            let tau = std::f64::consts::TAU;
1009            x[[i, 0]] = 1.0;
1010            x[[i, 1]] = t;
1011            x[[i, 2]] = (tau * t).sin();
1012            x[[i, 3]] = (tau * t).cos();
1013            x[[i, 4]] = (2.0 * tau * t).sin();
1014            x[[i, 5]] = (2.0 * tau * t).cos();
1015            x[[i, 6]] = (3.0 * tau * t).sin();
1016            x[[i, 7]] = (3.0 * tau * t).cos();
1017            let eta = 0.3 + 0.9 * (1.4 * (t - 0.5)).sin();
1018            // Deterministic non-negative integer counts near exp(eta).
1019            y[i] = (eta.exp() + 0.5 * ((i as f64) * 2.399_963).sin())
1020                .round()
1021                .max(0.0);
1022        }
1023        let mut s = Array2::<f64>::zeros((p, p));
1024        for j in 1..p {
1025            s[[j, j]] = 1.0;
1026        }
1027
1028        // Replicated design (c literal copies of each row).
1029        let mut x_rep = Array2::<f64>::zeros((n * c, p));
1030        let mut y_rep = Array1::<f64>::zeros(n * c);
1031        for r in 0..c {
1032            for i in 0..n {
1033                let row = r * n + i;
1034                for j in 0..p {
1035                    x_rep[[row, j]] = x[[i, j]];
1036                }
1037                y_rep[row] = y[i];
1038            }
1039        }
1040
1041        let w_weighted = Array1::<f64>::from_elem(n, c as f64);
1042        let w_rep = Array1::<f64>::ones(n * c);
1043
1044        let cfg = RemlConfig::external(poisson_log_glm_spec(), 1e-10, false);
1045        let st_w = build_logit_state(&y, &w_weighted, &x, &s, &cfg);
1046        let st_r = build_logit_state(&y_rep, &w_rep, &x_rep, &s, &cfg);
1047
1048        for &rho in &[-2.0_f64, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0] {
1049            let r = Array1::from_elem(1, rho);
1050            let cw = st_w.compute_cost(&r).expect("weighted cost");
1051            let cr = st_r.compute_cost(&r).expect("replicated cost");
1052            let gw = st_w.compute_gradient(&r).expect("weighted grad");
1053            let gr = st_r.compute_gradient(&r).expect("replicated grad");
1054            // Costs and gradients must coincide to optimiser precision; the only
1055            // admissible difference is f64 summation order over n vs c·n rows.
1056            assert!(
1057                (cw - cr).abs() <= 1e-9 * (1.0 + cw.abs()),
1058                "LAML cost differs between w=c and c× replication at rho={rho}: \
1059                 cost_w={cw:.12e} cost_r={cr:.12e} diff={:.3e}",
1060                cw - cr
1061            );
1062            assert!(
1063                (gw[0] - gr[0]).abs() <= 1e-9 * (1.0 + gw[0].abs()),
1064                "LAML gradient differs between w=c and c× replication at rho={rho}: \
1065                 g_w={:.12e} g_r={:.12e} diff={:.3e}",
1066                gw[0],
1067                gr[0],
1068                gw[0] - gr[0]
1069            );
1070        }
1071    }
1072
1073    /// Regression (issue #893): the geometric-mean log-weight ρ-anchor
1074    /// ([`RemlState::rho_weight_anchor`]) is a *profiled*-dispersion construct
1075    /// (issue #877). For a fixed-dispersion family the optimum does not slide by
1076    /// `log c` under a weight rescale in a way the prior should track, and a
1077    /// nonzero anchor would evaluate the regularising ρ-prior at *different*
1078    /// coordinates for the `w=c` vs `c×` encodings — breaking the very
1079    /// equivalence #893 requires. The anchor must therefore be exactly `0` for a
1080    /// fixed-dispersion family and the geometric mean for Gaussian-identity.
1081    #[test]
1082    pub(crate) fn rho_weight_anchor_is_zero_for_fixed_dispersion() {
1083        let n = 50usize;
1084        let p = 3usize;
1085        let mut x = Array2::<f64>::zeros((n, p));
1086        let mut y = Array1::<f64>::zeros(n);
1087        for i in 0..n {
1088            let t = (i as f64) / ((n - 1) as f64);
1089            x[[i, 0]] = 1.0;
1090            x[[i, 1]] = t;
1091            x[[i, 2]] = t * t;
1092            y[i] = (1.0 + (3.0 * t).sin()).round().max(0.0);
1093        }
1094        let mut s = Array2::<f64>::zeros((p, p));
1095        s[[2, 2]] = 1.0;
1096        // All weights = c: geometric-mean log-weight = ln(c) ≠ 0.
1097        let c = 4.0_f64;
1098        let w = Array1::<f64>::from_elem(n, c);
1099
1100        let cfg_pois = RemlConfig::external(poisson_log_glm_spec(), 1e-10, false);
1101        let st_pois = build_logit_state(&y, &w, &x, &s, &cfg_pois);
1102        assert_eq!(
1103            st_pois.rho_weight_anchor(),
1104            0.0,
1105            "fixed-dispersion (Poisson) anchor must be 0, not the geometric-mean log-weight"
1106        );
1107
1108        let cfg_gauss = RemlConfig::external(gaussian_identity_glm_spec(), 1e-10, false);
1109        let st_gauss = build_logit_state(&y, &w, &x, &s, &cfg_gauss);
1110        assert!(
1111            (st_gauss.rho_weight_anchor() - c.ln()).abs() <= 1e-12,
1112            "Gaussian-identity (profiled) anchor must be the geometric-mean log-weight ln(c)={:.6}, got {:.6}",
1113            c.ln(),
1114            st_gauss.rho_weight_anchor()
1115        );
1116    }
1117
1118    pub(crate) fn beta_original_from_bundle(bundle: &EvalShared) -> Array1<f64> {
1119        let pr = bundle.pirls_result.as_ref();
1120        match pr.coordinate_frame {
1121            PirlsCoordinateFrame::OriginalSparseNative => pr.beta_transformed.as_ref().clone(),
1122            PirlsCoordinateFrame::TransformedQs => {
1123                pr.reparam_result.qs.dot(pr.beta_transformed.as_ref())
1124            }
1125        }
1126    }
1127
1128    pub(crate) fn compute_joint_hypercostgradienthessian(
1129        state: &RemlState<'_>,
1130        theta: &Array1<f64>,
1131        rho_dim: usize,
1132        hyper_dirs: &[DirectionalHyperParam],
1133    ) -> Result<(f64, Array1<f64>, Array2<f64>), EstimationError> {
1134        let (cost, gradient, hessian) = state.compute_joint_hyper_eval_with_order(
1135            theta,
1136            rho_dim,
1137            hyper_dirs,
1138            crate::rho_optimizer::OuterEvalOrder::ValueGradientHessian,
1139        )?;
1140        Ok((
1141            cost,
1142            gradient,
1143            hessian
1144                .materialize_dense()
1145                .map_err(|error| EstimationError::RemlOptimizationFailed(error.to_string()))?
1146                .ok_or_else(|| {
1147                    EstimationError::RemlOptimizationFailed(
1148                        "joint hyper Hessian requested but unavailable".to_string(),
1149                    )
1150                })?,
1151        ))
1152    }
1153
1154    pub(crate) fn h_original_from_bundle(bundle: &EvalShared) -> Array2<f64> {
1155        let pr = bundle.pirls_result.as_ref();
1156        match pr.coordinate_frame {
1157            PirlsCoordinateFrame::OriginalSparseNative => bundle.h_total.as_ref().clone(),
1158            PirlsCoordinateFrame::TransformedQs => {
1159                let qs = &pr.reparam_result.qs;
1160                let tmp = gam_linalg::faer_ndarray::fast_ab(qs, bundle.h_total.as_ref());
1161                gam_linalg::faer_ndarray::fast_abt(&tmp, qs)
1162            }
1163        }
1164    }
1165
1166    pub(crate) fn single_directional_tau_gradient(
1167        state: &RemlState<'_>,
1168        rho: &Array1<f64>,
1169        hyper: DirectionalHyperParam,
1170    ) -> Result<f64, EstimationError> {
1171        let mut theta = Array1::<f64>::zeros(rho.len() + 1);
1172        theta.slice_mut(s![..rho.len()]).assign(rho);
1173        let (_, gradient, _) = state.compute_joint_hyper_eval_with_order(
1174            &theta,
1175            rho.len(),
1176            &[hyper],
1177            crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1178        )?;
1179        Ok(gradient[rho.len()])
1180    }
1181
1182    pub(crate) fn fd_directional_tau_cost_gradient(
1183        y: &Array1<f64>,
1184        w: &Array1<f64>,
1185        x: &Array2<f64>,
1186        s0: &Array2<f64>,
1187        cfg: &RemlConfig,
1188        rho: &Array1<f64>,
1189        x_tau: &Array2<f64>,
1190        s_tau: &Array2<f64>,
1191    ) -> f64 {
1192        let h = 2e-5;
1193        let x_plus = x + &x_tau.mapv(|v| h * v);
1194        let x_minus = x - &x_tau.mapv(|v| h * v);
1195        let s_plus = s0 + &s_tau.mapv(|v| h * v);
1196        let s_minus = s0 - &s_tau.mapv(|v| h * v);
1197        let state_plus = build_logit_state(y, w, &x_plus, &s_plus, cfg);
1198        let state_minus = build_logit_state(y, w, &x_minus, &s_minus, cfg);
1199        let v_plus = state_plus.compute_cost(rho).expect("cost+");
1200        let v_minus = state_minus.compute_cost(rho).expect("cost-");
1201        (v_plus - v_minus) / (2.0 * h)
1202    }
1203
1204    pub(crate) fn directional_tau_hessian_fd_reference(
1205        y: &Array1<f64>,
1206        w: &Array1<f64>,
1207        x: &Array2<f64>,
1208        s0: &Array2<f64>,
1209        cfg: &RemlConfig,
1210        rho: &Array1<f64>,
1211        hyper_dirs: &[DirectionalHyperParam],
1212        x_tau_mats: &[Array2<f64>],
1213        s_tau_mats: &[Array2<f64>],
1214    ) -> Array2<f64> {
1215        assert_eq!(hyper_dirs.len(), x_tau_mats.len());
1216        assert_eq!(hyper_dirs.len(), s_tau_mats.len());
1217
1218        const TARGET_PHYSICAL_STEP: f64 = 1e-5;
1219
1220        let n_dirs = hyper_dirs.len();
1221        let mut h_ttfd = Array2::<f64>::zeros((n_dirs, n_dirs));
1222        for j in 0..n_dirs {
1223            let direction_scale = x_tau_mats[j]
1224                .iter()
1225                .chain(s_tau_mats[j].iter())
1226                .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1227            let h = if direction_scale > 0.0 {
1228                TARGET_PHYSICAL_STEP / direction_scale
1229            } else {
1230                TARGET_PHYSICAL_STEP
1231            };
1232
1233            let x_plus = x + &x_tau_mats[j].mapv(|v| h * v);
1234            let x_minus = x - &x_tau_mats[j].mapv(|v| h * v);
1235            let s_plus = s0 + &s_tau_mats[j].mapv(|v| h * v);
1236            let s_minus = s0 - &s_tau_mats[j].mapv(|v| h * v);
1237
1238            let state_plus = build_logit_state(y, w, &x_plus, &s_plus, cfg);
1239            let state_minus = build_logit_state(y, w, &x_minus, &s_minus, cfg);
1240            for i in 0..n_dirs {
1241                let g_plus =
1242                    single_directional_tau_gradient(&state_plus, rho, hyper_dirs[i].clone())
1243                        .expect("g+ for FD");
1244                let g_minus =
1245                    single_directional_tau_gradient(&state_minus, rho, hyper_dirs[i].clone())
1246                        .expect("g- for FD");
1247                h_ttfd[[i, j]] = (g_plus - g_minus) / (2.0 * h);
1248            }
1249        }
1250        symmetrize_in_place(&mut h_ttfd);
1251        h_ttfd
1252    }
1253
1254    #[test]
1255    pub(crate) fn eval_cache_manager_stores_first_order_outer_eval() {
1256        let cache = EvalCacheManager::new();
1257        let rho = array![0.25, -0.0];
1258        let rho_key = super::rho_key::sanitized_rhokey(&rho);
1259        let eval = OuterEval {
1260            cost: 3.5,
1261            gradient: array![1.0, -2.0],
1262            hessian: HessianValue::Unavailable,
1263            inner_beta_hint: None,
1264        };
1265
1266        cache.store_outer_eval(&rho_key, &eval);
1267
1268        let cached = cache
1269            .cached_outer_eval(&rho_key)
1270            .expect("first-order outer eval should be cached");
1271        assert_eq!(cached.cost, eval.cost);
1272        assert_eq!(cached.gradient, eval.gradient);
1273        assert!(matches!(cached.hessian, HessianValue::Unavailable));
1274
1275        cache.invalidate_eval_bundle();
1276        assert!(
1277            cache.cached_outer_eval(&rho_key).is_none(),
1278            "invalidating the bundle should clear the outer-eval cache too"
1279        );
1280    }
1281
1282    /// #1575 multi-slot outer-eval cache correctness oracle.
1283    ///
1284    /// A memoization is only safe if a hit returns *exactly* what the miss path
1285    /// stored. This pins three properties of the bounded LRU:
1286    ///   1. round-trip fidelity — a hit is `f64::to_bits`-identical in cost AND
1287    ///      every gradient component to the value stored on the miss path;
1288    ///   2. no aliasing — distinct rho-keys never return each other's eval;
1289    ///   3. honest eviction — once an evicted key is requested again it MISSES
1290    ///      (so the caller recomputes) rather than returning a stale neighbour.
1291    #[test]
1292    pub(crate) fn outer_eval_lru_hit_is_bit_identical_and_evicts_honestly_1575() {
1293        use super::OUTER_EVAL_LRU_CAPACITY;
1294
1295        // Helper: a deterministic OuterEval whose bits encode `seed`, so any
1296        // cross-key contamination is detectable bit-for-bit.
1297        let make_eval = |seed: f64| OuterEval {
1298            cost: (seed * std::f64::consts::PI).sin() / 3.0 - seed,
1299            gradient: array![seed, -seed * 2.0, seed.recip()],
1300            hessian: HessianValue::Unavailable,
1301            inner_beta_hint: Some(array![seed + 0.5, seed - 0.5]),
1302        };
1303        let bits_eq = |a: &OuterEval, b: &OuterEval| -> bool {
1304            a.cost.to_bits() == b.cost.to_bits()
1305                && a.gradient.len() == b.gradient.len()
1306                && a.gradient
1307                    .iter()
1308                    .zip(b.gradient.iter())
1309                    .all(|(x, y)| x.to_bits() == y.to_bits())
1310        };
1311
1312        let cache = EvalCacheManager::new();
1313
1314        // (1) Round-trip fidelity: store at rho_a, then a forced hit must equal
1315        // the stored eval bit-for-bit (the "hit == miss" guarantee).
1316        let rho_a = array![0.25, -1.5];
1317        let key_a = super::rho_key::sanitized_rhokey(&rho_a);
1318        let eval_a = make_eval(0.25);
1319        cache.store_outer_eval(&key_a, &eval_a);
1320        let hit_a = cache
1321            .cached_outer_eval(&key_a)
1322            .expect("stored rho_a must hit");
1323        assert!(
1324            bits_eq(&hit_a, &eval_a),
1325            "cache hit must be bit-identical (cost+gradient) to the stored miss-path eval"
1326        );
1327        assert_eq!(
1328            hit_a.inner_beta_hint.as_ref().map(|b| b.to_vec()),
1329            eval_a.inner_beta_hint.as_ref().map(|b| b.to_vec()),
1330            "inner_beta_hint must round-trip unchanged"
1331        );
1332
1333        // (2) No aliasing: a second, distinct rho must return its OWN eval, and
1334        // the first key must still return the first eval untouched.
1335        let rho_b = array![0.25, -1.4999999999999998];
1336        let key_b = super::rho_key::sanitized_rhokey(&rho_b);
1337        assert_ne!(key_a, key_b, "the two rho-keys must differ");
1338        let eval_b = make_eval(7.0);
1339        cache.store_outer_eval(&key_b, &eval_b);
1340        assert!(
1341            bits_eq(
1342                &cache.cached_outer_eval(&key_b).expect("rho_b must hit"),
1343                &eval_b
1344            ),
1345            "rho_b must return its own eval, not rho_a's"
1346        );
1347        assert!(
1348            bits_eq(
1349                &cache
1350                    .cached_outer_eval(&key_a)
1351                    .expect("rho_a must still hit"),
1352                &eval_a
1353            ),
1354            "rho_a must be unaffected by the rho_b insert"
1355        );
1356
1357        // (3) Honest eviction: overflow the LRU with fresh keys. The
1358        // least-recently-used entry must be evicted and then MISS (forcing a
1359        // recompute), while a still-resident key returns its exact stored bits.
1360        let cache = EvalCacheManager::new();
1361        let mut keys = Vec::new();
1362        let mut evals = Vec::new();
1363        for i in 0..OUTER_EVAL_LRU_CAPACITY {
1364            let rho = array![i as f64, -(i as f64)];
1365            let key = super::rho_key::sanitized_rhokey(&rho);
1366            let eval = make_eval(i as f64 + 0.123);
1367            cache.store_outer_eval(&key, &eval);
1368            keys.push(key);
1369            evals.push(eval);
1370        }
1371        // Cache is exactly full; key[0] is the least-recently-used.
1372        assert_eq!(
1373            cache.outer_eval_lru.read().unwrap().entries.len(),
1374            OUTER_EVAL_LRU_CAPACITY
1375        );
1376        // One more distinct key evicts the LRU (key[0]).
1377        let rho_overflow = array![999.0, -999.0];
1378        let key_overflow = super::rho_key::sanitized_rhokey(&rho_overflow);
1379        let eval_overflow = make_eval(42.0);
1380        cache.store_outer_eval(&key_overflow, &eval_overflow);
1381        assert_eq!(
1382            cache.outer_eval_lru.read().unwrap().entries.len(),
1383            OUTER_EVAL_LRU_CAPACITY,
1384            "capacity must stay bounded"
1385        );
1386        assert!(
1387            cache.cached_outer_eval(&keys[0]).is_none(),
1388            "the least-recently-used key must be evicted and now MISS (recompute), not return stale"
1389        );
1390        assert!(
1391            bits_eq(
1392                &cache
1393                    .cached_outer_eval(&keys[1])
1394                    .expect("a still-resident key must hit"),
1395                &evals[1]
1396            ),
1397            "a still-resident key must return its exact stored bits"
1398        );
1399        assert!(
1400            bits_eq(
1401                &cache
1402                    .cached_outer_eval(&key_overflow)
1403                    .expect("the freshest key must hit"),
1404                &eval_overflow
1405            ),
1406            "the freshest key must hit with its own eval"
1407        );
1408    }
1409
1410    #[test]
1411    pub(crate) fn reset_outer_seed_state_clears_pirls_cache() {
1412        // Build a minimal logit RemlState, populate the cross-call PIRLS LRU
1413        // by evaluating the outer objective at one rho, then verify that
1414        // reset_outer_seed_state wipes that LRU (alongside the eval bundle
1415        // and warm-start signals). This pins down the cross-attempt
1416        // cleanup contract that a budget-bump retry relies on.
1417        let y = array![0.0, 1.0, 1.0, 0.0, 0.0, 1.0];
1418        let w = Array1::<f64>::ones(y.len());
1419        let x = array![
1420            [1.0, -1.0, 0.2],
1421            [1.0, -0.5, -0.4],
1422            [1.0, 0.0, 0.7],
1423            [1.0, 0.4, -0.3],
1424            [1.0, 0.9, 0.1],
1425            [1.0, 1.3, -0.6],
1426        ];
1427        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.1, 0.15], [0.0, 0.15, 0.8],];
1428        let rho = array![0.0];
1429        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false);
1430        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1431
1432        // Trigger a full outer eval so execute_pirls_if_needed inserts at
1433        // least one entry into the cross-call PIRLS LRU.
1434        state
1435            .compute_outer_eval_with_order(
1436                &rho,
1437                crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1438            )
1439            .expect("outer eval should succeed");
1440
1441        let populated_len = state.cache_manager.pirls_cache.read().unwrap().map.len();
1442        assert!(
1443            populated_len > 0,
1444            "evaluating the outer objective should populate the PIRLS LRU, got {populated_len}"
1445        );
1446
1447        state.reset_outer_seed_state();
1448
1449        let cleared_len = state.cache_manager.pirls_cache.read().unwrap().map.len();
1450        assert_eq!(
1451            cleared_len, 0,
1452            "reset_outer_seed_state must clear the cross-call PIRLS LRU; got {cleared_len} entries"
1453        );
1454    }
1455
1456    #[test]
1457    pub(crate) fn reset_outer_seed_state_preserves_frozen_negbin_theta_1448() {
1458        // #1448 regression: the NB outer θ↔λ alternation loop
1459        // (solver/estimate/optimizer.rs) re-runs the ρ search after each θ
1460        // refresh by (a) re-freezing the λ-search θ at θ_final into
1461        // `frozen_negbin_theta`, then (b) calling `reset_outer_seed_state()` to
1462        // drop the caches keyed to the old θ. Step (b) MUST NOT clear the freeze
1463        // set in step (a): the capture in `solve_for_unified_rho` only writes the
1464        // frozen slot when it is 0, so if the reset zeroed it the next round would
1465        // re-derive θ from the seed η and the loop would never reach the (ρ, θ)
1466        // joint fixed point — silently regressing #1448 back to a single
1467        // freeze→refresh pass.
1468        //
1469        // This pins the load-bearing distinction between `reset_outer_seed_state`
1470        // (alternation-round reset, freeze SURVIVES) and the surface-refresh reset
1471        // (new design, freeze re-zeroed). The end-to-end convergence on a real NB
1472        // fit is exercised by the public-API path; here we lock the invariant the
1473        // loop depends on, next to `reset_outer_seed_state_clears_pirls_cache`.
1474        use std::sync::atomic::Ordering;
1475
1476        let y = array![0.0, 1.0, 1.0, 0.0, 0.0, 1.0];
1477        let w = Array1::<f64>::ones(y.len());
1478        let x = array![
1479            [1.0, -1.0, 0.2],
1480            [1.0, -0.5, -0.4],
1481            [1.0, 0.0, 0.7],
1482            [1.0, 0.4, -0.3],
1483            [1.0, 0.9, 0.1],
1484            [1.0, 1.3, -0.6],
1485        ];
1486        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.1, 0.15], [0.0, 0.15, 0.8],];
1487        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false);
1488        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1489
1490        // Simulate the alternation loop's re-freeze step: pin θ_final.
1491        let theta_final_bits = 2.5_f64.to_bits();
1492        state
1493            .frozen_negbin_theta
1494            .store(theta_final_bits, Ordering::Relaxed);
1495        assert_eq!(
1496            state.frozen_negbin_theta.load(Ordering::Relaxed),
1497            theta_final_bits,
1498            "precondition: the re-freeze stores θ_final into the frozen slot"
1499        );
1500
1501        // The alternation loop's per-round reset.
1502        state.reset_outer_seed_state();
1503
1504        assert_eq!(
1505            state.frozen_negbin_theta.load(Ordering::Relaxed),
1506            theta_final_bits,
1507            "reset_outer_seed_state (alternation-round reset) must PRESERVE the \
1508             re-frozen NB θ; clearing it would defeat the #1448 θ↔λ alternation \
1509             (the next ρ search would re-derive θ from the seed and never reach \
1510             the joint fixed point)"
1511        );
1512    }
1513
1514    #[test]
1515    pub(crate) fn implicit_hyper_design_derivative_respects_full_model_embedding() {
1516        let operator = ImplicitDesignPsiDerivative::new(
1517            array![1.0, 2.0, 3.0, 4.0],
1518            array![0.5, -1.0, 1.5, 2.0],
1519            array![0.1, 0.2, 0.3, 0.4],
1520            array![[1.0, 0.2], [0.5, 0.1], [1.5, 0.3], [2.0, 0.4]],
1521            None,
1522            None,
1523            2,
1524            2,
1525            1,
1526            2,
1527        );
1528        let local = operator
1529            .materialize_first(0)
1530            .expect("materialized first derivative");
1531        assert_eq!(
1532            local.ncols(),
1533            3,
1534            "operator-local derivative should stay smooth-local"
1535        );
1536
1537        let implicit = HyperDesignDerivative::from_implicit(
1538            Arc::new(operator),
1539            ImplicitDerivLevel::First(0),
1540            1..4,
1541            5,
1542        );
1543        let embedded = HyperDesignDerivative::from_embedded(local.clone(), 1..4, 5);
1544
1545        assert_eq!(implicit.nrows(), embedded.nrows());
1546        assert_eq!(implicit.ncols(), 5);
1547        assert_eq!(implicit.materialize(), embedded.materialize());
1548
1549        let u = array![7.0, 1.5, -2.0, 0.25, -3.0];
1550        let v = array![0.75, -1.25];
1551        assert_eq!(
1552            implicit.forward_mul_original(&u).expect("implicit forward"),
1553            embedded.forward_mul_original(&u).expect("embedded forward")
1554        );
1555        assert_eq!(
1556            implicit
1557                .transpose_mul_original(&v)
1558                .expect("implicit transpose"),
1559            embedded
1560                .transpose_mul_original(&v)
1561                .expect("embedded transpose")
1562        );
1563
1564        let qs = array![
1565            [1.0, 0.0, 0.0],
1566            [0.0, 1.0, 0.0],
1567            [0.0, 0.5, 0.5],
1568            [0.0, 0.0, 1.0],
1569            [0.0, 0.0, 0.0],
1570        ];
1571        assert_eq!(
1572            implicit
1573                .transformed(&qs, None)
1574                .expect("implicit transformed"),
1575            embedded
1576                .transformed(&qs, None)
1577                .expect("embedded transformed")
1578        );
1579        let u_transformed = array![1.0, -0.5, 2.0];
1580        assert_eq!(
1581            implicit
1582                .transformed_forward_mul(&qs, None, &u_transformed)
1583                .expect("implicit transformed forward"),
1584            embedded
1585                .transformed_forward_mul(&qs, None, &u_transformed)
1586                .expect("embedded transformed forward")
1587        );
1588        assert_eq!(
1589            implicit
1590                .transformed_transpose_mul(&qs, None, &v)
1591                .expect("implicit transformed transpose"),
1592            embedded
1593                .transformed_transpose_mul(&qs, None, &v)
1594                .expect("embedded transformed transpose")
1595        );
1596    }
1597
1598    #[test]
1599    pub(crate) fn directional_hyper_identities_match_finite_differences_logit() {
1600        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
1601        let w = Array1::<f64>::ones(y.len());
1602        let x = array![
1603            [1.0, -1.2, 0.3],
1604            [1.0, -0.8, -0.4],
1605            [1.0, -0.3, 0.7],
1606            [1.0, 0.1, -0.9],
1607            [1.0, 0.5, 0.2],
1608            [1.0, 0.9, -0.1],
1609            [1.0, 1.3, 0.8],
1610            [1.0, 1.7, -0.6],
1611        ];
1612        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
1613
1614        // Use one directional hyperparameter τ with a penalty perturbation:
1615        // S(τ) = S + τ S_τ.
1616        // Keep X_τ = 0 so this identity test remains valid in both non-Firth
1617        // and Firth-logit modes.
1618        let x_tau = Array2::<f64>::zeros(x.raw_dim());
1619        let s_tau = array![[0.0, 0.0, 0.0], [0.0, 0.25, 0.04], [0.0, 0.04, 0.15],];
1620        let hyper =
1621            DirectionalHyperParam::single_penalty(0, x_tau.clone(), s_tau.clone(), None, None)
1622                .expect("single-penalty hyper direction");
1623        let rho = array![0.0];
1624
1625        // Tight inner tolerance: the envelope theorem requires an exact inner
1626        // P-IRLS optimum; 1e-10 leaves enough residual gradient to cause ~12%
1627        // V_tau mismatch on this small (n=8) logistic problem.
1628        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
1629        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1630        let bundle = state.obtain_eval_bundle(&rho).expect("bundle");
1631        let pr = bundle.pirls_result.as_ref();
1632
1633        let beta = beta_original_from_bundle(&bundle);
1634        let h_orig = h_original_from_bundle(&bundle);
1635        let u = &pr.solveweights * &(&pr.solveworking_response - &pr.final_eta);
1636
1637        // B from implicit solve:
1638        //   H B = X_τ^T g - X^T W(X_τ β̂) - S_τ β̂.
1639        let x_tau_beta = gam_linalg::faer_ndarray::fast_av(&x_tau, &beta);
1640        let weighted_x_tau_beta = &pr.finalweights * &x_tau_beta;
1641        let rhs = gam_linalg::faer_ndarray::fast_atv(&x_tau, &u)
1642            - gam_linalg::faer_ndarray::fast_atv(&x, &weighted_x_tau_beta)
1643            - s_tau.dot(&beta);
1644        let chol = h_orig.cholesky(Side::Lower).expect("chol(H)");
1645        let b_analytic = chol.solvevec(&rhs);
1646
1647        // H_τ from exact total derivative:
1648        //   H_τ = X_τ^T W X + X^T W X_τ + X^T W_τ X + S_τ,
1649        // with W_τ provided by the family directional curvature callback.
1650        let eta_dot = &x_tau_beta + &gam_linalg::faer_ndarray::fast_av(&x, &b_analytic);
1651        let w_direction = crate::pirls::directionalworking_curvature_from_c_array(
1652            &pr.solve_c_array.to_owned(),
1653            &eta_dot,
1654        );
1655        let wx = RemlState::row_scale(&x, &pr.finalweights.to_owned());
1656        let wx_tau = RemlState::row_scale(&x_tau, &pr.finalweights.to_owned());
1657        let mut xwtau_x = x.clone();
1658        match w_direction {
1659            crate::pirls::DirectionalWorkingCurvature::Diagonal(diag) => {
1660                xwtau_x = RemlState::row_scale(&xwtau_x, &diag);
1661            }
1662        }
1663        let mut h_tau_analytic = gam_linalg::faer_ndarray::fast_atb(&x_tau, &wx);
1664        h_tau_analytic += &gam_linalg::faer_ndarray::fast_atb(&x, &wx_tau);
1665        h_tau_analytic += &gam_linalg::faer_ndarray::fast_atb(&x, &xwtau_x);
1666        h_tau_analytic += &s_tau;
1667
1668        // Fit-block stationarity cancellation:
1669        //   -ℓ_β^T B + β̂^T S B = 0.
1670        // Here S is the effective penalty in the inner Hessian surface:
1671        //   S = H - X^T W X.
1672        let ell_beta = gam_linalg::faer_ndarray::fast_atv(&x, &u);
1673        let s_eff = &h_orig - &gam_linalg::faer_ndarray::fast_atb(&x, &wx);
1674        let cancellation = -ell_beta.dot(&b_analytic) + beta.dot(&s_eff.dot(&b_analytic));
1675
1676        // Finite differences in τ against re-fit objective and mode.
1677        let h = 2e-5;
1678        let x_plus = &x + &(x_tau.mapv(|v| h * v));
1679        let x_minus = &x - &(x_tau.mapv(|v| h * v));
1680        let s_plus = &s0 + &(s_tau.mapv(|v| h * v));
1681        let s_minus = &s0 - &(s_tau.mapv(|v| h * v));
1682
1683        let state_plus = build_logit_state(&y, &w, &x_plus, &s_plus, &cfg);
1684        let state_minus = build_logit_state(&y, &w, &x_minus, &s_minus, &cfg);
1685        let bundle_plus = state_plus.obtain_eval_bundle(&rho).expect("bundle+");
1686        let bundle_minus = state_minus.obtain_eval_bundle(&rho).expect("bundle-");
1687        let beta_plus = beta_original_from_bundle(&bundle_plus);
1688        let beta_minus = beta_original_from_bundle(&bundle_minus);
1689        let bfd = (&beta_plus - &beta_minus).mapv(|v| v / (2.0 * h));
1690
1691        let h_plus = h_original_from_bundle(&bundle_plus);
1692        let h_minus = h_original_from_bundle(&bundle_minus);
1693        let h_taufd = (&h_plus - &h_minus).mapv(|v| v / (2.0 * h));
1694
1695        let v_plus = state_plus.compute_cost(&rho).expect("cost+");
1696        let v_minus = state_minus.compute_cost(&rho).expect("cost-");
1697        let v_taufd = (v_plus - v_minus) / (2.0 * h);
1698
1699        let v_tau_analytic = single_directional_tau_gradient(&state, &rho, hyper.clone())
1700            .expect("analytic directional gradient");
1701
1702        let b_num = (&b_analytic - &bfd).mapv(|v| v * v).sum().sqrt();
1703        let b_den = bfd.mapv(|v| v * v).sum().sqrt().max(1e-12);
1704        let b_rel = b_num / b_den;
1705        for i in 0..b_analytic.len() {
1706            assert_eq!(
1707                b_analytic[i].signum(),
1708                bfd[i].signum(),
1709                "B sign mismatch at i={i}: analytic={} fd={}",
1710                b_analytic[i],
1711                bfd[i]
1712            );
1713        }
1714        assert!(
1715            b_rel < 2e-2,
1716            "B implicit solve mismatch vs FD: rel={b_rel:.3e}, num={b_num:.3e}, den={b_den:.3e}"
1717        );
1718
1719        let dh_num = (&h_tau_analytic - &h_taufd).mapv(|v| v * v).sum().sqrt();
1720        let dh_den = h_taufd.mapv(|v| v * v).sum().sqrt().max(1e-12);
1721        let dh_rel = dh_num / dh_den;
1722        for i in 0..h_tau_analytic.nrows() {
1723            for j in 0..h_tau_analytic.ncols() {
1724                assert_eq!(
1725                    h_tau_analytic[[i, j]].signum(),
1726                    h_taufd[[i, j]].signum(),
1727                    "H_tau sign mismatch at ({i},{j}): analytic={} fd={}",
1728                    h_tau_analytic[[i, j]],
1729                    h_taufd[[i, j]]
1730                );
1731            }
1732        }
1733        assert!(
1734            dh_rel < 3e-2,
1735            "H_tau mismatch vs FD: rel={dh_rel:.3e}, num={dh_num:.3e}, den={dh_den:.3e}"
1736        );
1737
1738        let v_abs = (v_tau_analytic - v_taufd).abs();
1739        let v_rel = v_abs / v_taufd.abs().max(1e-10);
1740        assert_eq!(
1741            v_tau_analytic.signum(),
1742            v_taufd.signum(),
1743            "V_tau sign mismatch: analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
1744        );
1745        assert!(
1746            v_rel < 2e-2,
1747            "V_tau mismatch vs FD: rel={v_rel:.3e}, abs={v_abs:.3e}, analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
1748        );
1749
1750        assert!(
1751            cancellation.abs() < 1e-10,
1752            "stationarity cancellation failed: | -ell_beta^T B + beta^T S B | = {:.3e}",
1753            cancellation.abs()
1754        );
1755    }
1756
1757    #[test]
1758    pub(crate) fn firth_exacthessian_includes_analytic_tk_second_derivatives() {
1759        // Rank-deficient X: the 4th column is 2x the 2nd column.
1760        let y = array![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
1761        let w = Array1::<f64>::ones(y.len());
1762        let x = array![
1763            [1.0, -1.2, 0.4, -2.4],
1764            [1.0, -0.9, -0.1, -1.8],
1765            [1.0, -0.6, 0.3, -1.2],
1766            [1.0, -0.2, -0.4, -0.4],
1767            [1.0, 0.1, 0.5, 0.2],
1768            [1.0, 0.4, -0.6, 0.8],
1769            [1.0, 0.8, 0.2, 1.6],
1770            [1.0, 1.1, -0.3, 2.2],
1771            [1.0, 1.4, 0.7, 2.8],
1772            [1.0, 1.7, -0.2, 3.4],
1773        ];
1774        let s0 = array![
1775            [0.0, 0.0, 0.0, 0.0],
1776            [0.0, 1.5, 0.2, 0.0],
1777            [0.0, 0.2, 1.0, 0.0],
1778            [0.0, 0.0, 0.0, 0.5],
1779        ];
1780        let s1 = array![
1781            [0.0, 0.0, 0.0, 0.0],
1782            [0.0, 0.8, -0.1, 0.0],
1783            [0.0, -0.1, 0.6, 0.0],
1784            [0.0, 0.0, 0.0, 0.3],
1785        ];
1786        let offset = Array1::<f64>::zeros(y.len());
1787        // Rank-deficient Firth logit needs more inner iterations to converge
1788        // tightly enough for the envelope-theorem derivative tests.
1789        let cfg =
1790            RemlConfig::external(binomial_logit_glm_spec(), 1e-9, true).with_max_iterations(500);
1791        let p = x.ncols();
1792        use crate::estimate::PenaltySpec;
1793        let specs = vec![PenaltySpec::Dense(s0), PenaltySpec::Dense(s1)];
1794        let canonical =
1795            gam_terms::construction::canonicalize_penalty_specs(&specs, &[1, 1], p, "test")
1796                .map(|(canonical, _)| canonical)
1797                .expect("canonicalize");
1798        let state = RemlState::newwith_offset(
1799            y.view(),
1800            x.clone(),
1801            w.view(),
1802            offset.view(),
1803            canonical,
1804            p,
1805            &cfg,
1806            Some(vec![1, 1]),
1807            None,
1808            None,
1809        )
1810        .expect("state");
1811        let rho = array![0.1, -0.2];
1812        assert!(
1813            state.analytic_outer_hessian_enabled(),
1814            "Firth logit should no longer disable analytic outer Hessian planning"
1815        );
1816        let outer = state
1817            .compute_outer_eval_with_order(
1818                &rho,
1819                crate::rho_optimizer::OuterEvalOrder::ValueGradientHessian,
1820            )
1821            .expect("outer Hessian eval should succeed");
1822        assert!(
1823            outer.hessian.is_analytic(),
1824            "outer planner should request and return an analytic Hessian"
1825        );
1826        let bundle = state.obtain_eval_bundle(&rho).expect("exact firth bundle");
1827        let h_dense = state
1828            .compute_lamlhessian_exact_from_bundle(&rho, &bundle)
1829            .expect("Firth exact Hessian should include analytic TK second derivatives");
1830        assert_eq!(h_dense.raw_dim(), ndarray::Ix2(2, 2));
1831        assert!(
1832            h_dense.iter().all(|value| value.is_finite()),
1833            "Hessian should be finite: {h_dense:?}"
1834        );
1835    }
1836
1837    #[test]
1838    pub(crate) fn firth_outer_hessian_matches_gradient_finite_difference_with_tk_terms() {
1839        let y = array![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
1840        let w = Array1::<f64>::ones(y.len());
1841        let x = array![
1842            [1.0, -1.0, 0.3],
1843            [1.0, -0.7, -0.2],
1844            [1.0, -0.3, 0.4],
1845            [1.0, 0.0, -0.5],
1846            [1.0, 0.2, 0.6],
1847            [1.0, 0.6, -0.4],
1848            [1.0, 0.9, 0.2],
1849            [1.0, 1.3, -0.1],
1850        ];
1851        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.1], [0.0, 0.1, 0.7],];
1852        let s1 = array![[0.0, 0.0, 0.0], [0.0, 0.4, -0.05], [0.0, -0.05, 0.9],];
1853        let cfg =
1854            RemlConfig::external(binomial_logit_glm_spec(), 1e-9, true).with_max_iterations(500);
1855        let p_dim = x.ncols();
1856        use crate::estimate::PenaltySpec;
1857        let specs = vec![PenaltySpec::Dense(s0), PenaltySpec::Dense(s1)];
1858        let canonical =
1859            gam_terms::construction::canonicalize_penalty_specs(&specs, &[1, 1], p_dim, "test")
1860                .map(|(canonical, _)| canonical)
1861                .expect("canonicalize");
1862        let offset = Array1::<f64>::zeros(y.len());
1863        let state = RemlState::newwith_offset(
1864            y.view(),
1865            x.clone(),
1866            w.view(),
1867            offset.view(),
1868            canonical,
1869            p_dim,
1870            &cfg,
1871            Some(vec![1, 1]),
1872            None,
1873            None,
1874        )
1875        .expect("state");
1876        let rho = array![0.15, -0.25];
1877        let eval = state
1878            .compute_outer_eval_with_order(
1879                &rho,
1880                crate::rho_optimizer::OuterEvalOrder::ValueGradientHessian,
1881            )
1882            .expect("analytic Hessian eval");
1883        let h = match eval.hessian {
1884            HessianValue::Dense(hessian) => hessian,
1885            HessianValue::Operator(_) | HessianValue::Unavailable => {
1886                panic!("expected dense analytic Hessian")
1887            }
1888        };
1889        let delta = 2.0e-5;
1890        for col in 0..rho.len() {
1891            let mut rp = rho.clone();
1892            let mut rm = rho.clone();
1893            rp[col] += delta;
1894            rm[col] -= delta;
1895            let gp = state
1896                .compute_outer_eval_with_order(
1897                    &rp,
1898                    crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1899                )
1900                .expect("plus grad")
1901                .gradient;
1902            let gm = state
1903                .compute_outer_eval_with_order(
1904                    &rm,
1905                    crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1906                )
1907                .expect("minus grad")
1908                .gradient;
1909            for row in 0..rho.len() {
1910                let fd = (gp[row] - gm[row]) / (2.0 * delta);
1911                let an = h[[row, col]];
1912                let rel = (fd - an).abs() / fd.abs().max(an.abs()).max(1e-6);
1913                assert!(
1914                    rel < 2.0e-3,
1915                    "Hessian mismatch ({row},{col}): analytic={an:.9e}, fd={fd:.9e}, rel={rel:.3e}"
1916                );
1917            }
1918        }
1919    }
1920
1921    #[test]
1922    pub(crate) fn firthgradient_lives_in_design_column_space_under_rank_deficiency() {
1923        // Rank-deficient design: col4 = 2*col2.
1924        let x = array![
1925            [1.0, -1.2, 0.4, -2.4],
1926            [1.0, -0.9, -0.1, -1.8],
1927            [1.0, -0.6, 0.3, -1.2],
1928            [1.0, -0.2, -0.4, -0.4],
1929            [1.0, 0.1, 0.5, 0.2],
1930            [1.0, 0.4, -0.6, 0.8],
1931            [1.0, 0.8, 0.2, 1.6],
1932            [1.0, 1.1, -0.3, 2.2],
1933        ];
1934        let beta = array![0.1, -0.2, 0.3, 0.05];
1935        let eta = x.dot(&beta);
1936        let op = super::RemlState::build_firth_dense_operator_for_link(
1937            &gam_problem::InverseLink::Standard(gam_problem::StandardLink::Logit),
1938            &x,
1939            &eta,
1940            ndarray::Array1::ones(x.nrows()).view(),
1941        )
1942        .expect("firth operator");
1943
1944        // Exact reduced-space Firth gradient:
1945        //   gradPhi = 0.5 Xᵀ (w' ⊙ h), with h = diag(X_r K_r X_rᵀ).
1946        let gradphi = 0.5 * x.t().dot(&(&op.w1 * &op.h_diag));
1947
1948        // Check (I - QQᵀ) gradPhi ≈ 0.
1949        let q = &op.q_basis;
1950        let proj = q.dot(&q.t().dot(&gradphi));
1951        let resid = &gradphi - &proj;
1952        let rel =
1953            resid.mapv(|v| v * v).sum().sqrt() / gradphi.mapv(|v| v * v).sum().sqrt().max(1e-12);
1954        assert!(
1955            rel < 1e-10,
1956            "Firth gradient should lie in Col(Xᵀ): rel residual={rel:.3e}"
1957        );
1958    }
1959
1960    #[test]
1961    pub(crate) fn firth_logit_directional_hypergradient_accepts_penalty_only_with_full_tk_gradient()
1962    {
1963        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
1964        let w = Array1::<f64>::ones(y.len());
1965        let x = array![
1966            [1.0, -1.1, 0.2],
1967            [1.0, -0.6, -0.3],
1968            [1.0, -0.1, 0.5],
1969            [1.0, 0.3, -0.7],
1970            [1.0, 0.8, 0.1],
1971            [1.0, 1.2, -0.4],
1972        ];
1973        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
1974        let hyper = DirectionalHyperParam::single_penalty(
1975            0,
1976            Array2::<f64>::zeros((x.nrows(), x.ncols())),
1977            array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.03], [0.0, 0.03, 0.12],],
1978            None,
1979            None,
1980        )
1981        .expect("single-penalty hyper direction");
1982        let rho = array![0.0];
1983        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-8, true);
1984        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1985        let gradient = single_directional_tau_gradient(&state, &rho, hyper)
1986            .expect("Firth penalty-only directional gradient should use analytic TK propagation");
1987        assert!(gradient.is_finite(), "gradient={gradient}");
1988        let fd = fd_directional_tau_cost_gradient(
1989            &y,
1990            &w,
1991            &x,
1992            &s0,
1993            &cfg,
1994            &rho,
1995            &Array2::<f64>::zeros((x.nrows(), x.ncols())),
1996            &array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.03], [0.0, 0.03, 0.12],],
1997        );
1998        let rel = (gradient - fd).abs() / gradient.abs().max(fd.abs()).max(1.0e-10);
1999        assert!(
2000            rel < 1.0e-3,
2001            "Firth penalty-only directional gradient mismatch: analytic={gradient:.12e}, fd={fd:.12e}, rel={rel:.3e}"
2002        );
2003
2004        let efs_hyper = DirectionalHyperParam::single_penalty(
2005            0,
2006            Array2::<f64>::zeros((x.nrows(), x.ncols())),
2007            array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.03], [0.0, 0.03, 0.12],],
2008            None,
2009            None,
2010        )
2011        .expect("single-penalty EFS hyper direction");
2012        let efs = state
2013            .compute_efs_steps_with_psi_ext(&rho, &[efs_hyper])
2014            .expect("Firth penalty-only EFS should use analytic TK propagation");
2015        assert!(efs.cost.is_finite(), "efs cost={}", efs.cost);
2016    }
2017
2018    /// Regression for gam#1821: the analytic ρ-gradient of the Firth-corrected
2019    /// LAML cost must equal the central finite difference of that SAME cost,
2020    /// evaluated END-TO-END through the inner P-IRLS solve (`compute_cost` /
2021    /// `compute_gradient`), for a genuinely Firth-active (near-separable) fit.
2022    ///
2023    /// This exercises the branch the earlier operator-level Firth FD tests never
2024    /// touched: the LM line-search that produces β̂. The dense LAML gradient uses
2025    /// the envelope identity, which holds ONLY when β̂ satisfies the *Firth*-KKT
2026    /// stationarity `∇(−ℓ+½βᵀSβ) = ∇Φ`. When `GamWorkingModel::update_candidate`
2027    /// built line-search candidates with Firth disabled, the candidate/accepted
2028    /// `WorkingState` dropped the `−2·½log|XᵀWX|` Jeffreys term, the objective
2029    /// the line search compared (candidate vs `current_penalized`) was
2030    /// inconsistent, and — because the accepted state IS the candidate and
2031    /// convergence is certified on `accepted_state.gradient` — the inner solve
2032    /// settled at the ordinary penalized MLE (`∇(−ℓ+½βᵀSβ)=0`) instead. At that
2033    /// wrong mode the envelope breaks and the analytic ρ-gradient disagrees with
2034    /// the FD of the cost by `O((∇Φ)ᵀ∂β̂/∂ρ)` — a large (percent-level) desync
2035    /// that appears ONLY under `firth_bias_reduction`. A tight FD-vs-analytic
2036    /// bound therefore fails iff the inner solve regresses off the Firth mode.
2037    #[test]
2038    pub(crate) fn firth_logit_rho_gradient_matches_finite_difference_through_inner_solve() {
2039        // Near-separable n=3 logit: Firth is materially active (the ordinary
2040        // penalized MLE and the Firth-penalized mode differ enough that any
2041        // envelope break shows up far above the 1e-4 tolerance).
2042        let x = array![[1.0, -6.0], [1.0, 0.2], [1.0, 5.8]];
2043        let y = array![0.0, 0.0, 1.0];
2044        let w = Array1::<f64>::ones(y.len());
2045        // Full-rank identity penalty on both coefficients (single ρ).
2046        let s0 = array![[1.0, 0.0], [0.0, 1.0]];
2047        // Tight inner tolerance so β̂(ρ ± δ) and β̂(ρ) are all at the converged
2048        // Firth-KKT mode; otherwise the FD would capture β̂'s residual motion.
2049        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-12, true);
2050        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2051        let delta = 1e-4_f64;
2052        for &rho in &[-0.6_f64, -0.3, 0.0, 0.3, 0.6] {
2053            let r = array![rho];
2054            let analytic = state
2055                .compute_gradient(&r)
2056                .expect("Firth LAML ρ-gradient should evaluate")[0];
2057            let cost_plus = state
2058                .compute_cost(&array![rho + delta])
2059                .expect("Firth LAML cost(ρ+δ) should evaluate");
2060            let cost_minus = state
2061                .compute_cost(&array![rho - delta])
2062                .expect("Firth LAML cost(ρ−δ) should evaluate");
2063            let fd = (cost_plus - cost_minus) / (2.0 * delta);
2064            let rel = (fd - analytic).abs() / fd.abs().max(1e-3);
2065            assert!(
2066                analytic.is_finite() && fd.is_finite(),
2067                "non-finite Firth ρ-gradient at rho={rho:+.3}: fd={fd:+.6e}, analytic={analytic:+.6e}"
2068            );
2069            assert!(
2070                rel < 1e-4,
2071                "Firth ρ-gradient FD desync at rho={rho:+.3}: fd={fd:+.6e}, analytic={analytic:+.6e}, rel={rel:.3e} (>= 1e-4). \
2072                 The inner P-IRLS likely converged off the Firth-KKT mode (gam#1821)."
2073            );
2074        }
2075    }
2076
2077    #[test]
2078    pub(crate) fn firth_logit_directional_hypergradient_accepts_design_moving_with_full_tk_gradient()
2079     {
2080        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
2081        let w = Array1::<f64>::ones(y.len());
2082        let x = array![
2083            [1.0, -1.1, 0.2],
2084            [1.0, -0.6, -0.3],
2085            [1.0, -0.1, 0.5],
2086            [1.0, 0.3, -0.7],
2087            [1.0, 0.8, 0.1],
2088            [1.0, 1.2, -0.4],
2089        ];
2090        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2091        let hyper = DirectionalHyperParam::single_penalty(
2092            0,
2093            Array2::from_elem((x.nrows(), x.ncols()), 1e-3),
2094            Array2::<f64>::zeros((x.ncols(), x.ncols())),
2095            None,
2096            None,
2097        )
2098        .expect("single-penalty hyper direction");
2099        let rho = array![0.0];
2100        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-8, true);
2101        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2102        let gradient = single_directional_tau_gradient(&state, &rho, hyper)
2103            .expect("Firth design-moving directional gradient should use analytic TK propagation");
2104        assert!(gradient.is_finite(), "gradient={gradient}");
2105        let x_tau = Array2::from_elem((x.nrows(), x.ncols()), 1e-3);
2106        let s_tau = Array2::<f64>::zeros((x.ncols(), x.ncols()));
2107        let fd = fd_directional_tau_cost_gradient(&y, &w, &x, &s0, &cfg, &rho, &x_tau, &s_tau);
2108        let rel = (gradient - fd).abs() / gradient.abs().max(fd.abs()).max(1.0e-10);
2109        assert!(
2110            rel < 2.0e-2,
2111            "Firth design-moving directional gradient mismatch: analytic={gradient:.12e}, fd={fd:.12e}, rel={rel:.3e}"
2112        );
2113    }
2114
2115    #[test]
2116    pub(crate) fn firth_logit_hybrid_efs_accepts_full_tk_psi_gradient() {
2117        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
2118        let w = Array1::<f64>::ones(y.len());
2119        let x = array![
2120            [1.0, -1.1, 0.2],
2121            [1.0, -0.6, -0.3],
2122            [1.0, -0.1, 0.5],
2123            [1.0, 0.3, -0.7],
2124            [1.0, 0.8, 0.1],
2125            [1.0, 1.2, -0.4],
2126        ];
2127        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2128        let hyper_dirs = vec![
2129            DirectionalHyperParam::single_penalty(
2130                0,
2131                Array2::from_shape_fn((x.nrows(), x.ncols()), |(i, j)| {
2132                    1e-3 * ((i + 1) as f64) * ((j + 2) as f64)
2133                }),
2134                Array2::<f64>::zeros((x.ncols(), x.ncols())),
2135                None,
2136                None,
2137            )
2138            .expect("design-moving hyper direction"),
2139        ];
2140        let rho = array![0.0];
2141        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-8, true);
2142        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2143
2144        let full = state
2145            .evaluate_unified_with_psi_ext(
2146                &rho,
2147                None,
2148                crate::estimate::reml::reml_outer_engine::EvalMode::ValueAndGradient,
2149                &hyper_dirs,
2150            )
2151            .expect("full Firth psi gradient should use analytic TK propagation");
2152        assert!(full.cost.is_finite(), "full cost={}", full.cost);
2153        let full_grad = full.gradient.expect("gradient should be present");
2154        assert!(
2155            full_grad.iter().all(|value| value.is_finite()),
2156            "full gradient={full_grad:?}"
2157        );
2158
2159        let efs = state
2160            .compute_efs_steps_with_psi_ext(&rho, &hyper_dirs)
2161            .expect("hybrid EFS should use analytic TK propagation");
2162        assert!(efs.cost.is_finite(), "efs cost={}", efs.cost);
2163    }
2164
2165    #[test]
2166    pub(crate) fn joint_hyperhessianwires_mixed_blocks() {
2167        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
2168        let w = Array1::<f64>::ones(y.len());
2169        let x = array![
2170            [1.0, -1.2, 0.3],
2171            [1.0, -0.8, -0.4],
2172            [1.0, -0.3, 0.7],
2173            [1.0, 0.1, -0.9],
2174            [1.0, 0.5, 0.2],
2175            [1.0, 0.9, -0.1],
2176            [1.0, 1.3, 0.8],
2177            [1.0, 1.7, -0.6],
2178        ];
2179        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
2180        let cfg =
2181            RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false).with_max_iterations(500);
2182        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2183        let rho = array![0.0];
2184        let theta = array![0.0, 0.0, 0.0];
2185        let hyper_dirs = vec![
2186            DirectionalHyperParam::single_penalty(
2187                0,
2188                Array2::<f64>::zeros((x.nrows(), x.ncols())),
2189                array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15],],
2190                None,
2191                None,
2192            )
2193            .expect("single-penalty hyper direction"),
2194            DirectionalHyperParam::single_penalty(
2195                0,
2196                Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2197                Array2::<f64>::zeros((x.ncols(), x.ncols())),
2198                None,
2199                None,
2200            )
2201            .expect("single-penalty hyper direction"),
2202        ];
2203
2204        let (_, _, h) =
2205            compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2206                .expect("joint hyper cost+gradient+hessian");
2207        assert_eq!(h.nrows(), theta.len());
2208        assert_eq!(h.ncols(), theta.len());
2209        assert!(h.iter().all(|v| v.is_finite()));
2210        for i in 0..h.nrows() {
2211            for j in 0..i {
2212                let diff = (h[[i, j]] - h[[j, i]]).abs();
2213                assert!(
2214                    diff < 1e-6,
2215                    "joint hessian asymmetry at ({i},{j}): {diff:.3e}"
2216                );
2217            }
2218        }
2219        // Mixed block must be nontrivial for at least one supplied direction.
2220        let mixed_0 = h[[0, 1]];
2221        let mixed_1 = h[[0, 2]];
2222        assert!(
2223            mixed_0.is_finite() && mixed_1.is_finite(),
2224            "mixed blocks must be finite"
2225        );
2226    }
2227
2228    #[test]
2229    pub(crate) fn joint_tau_tau_linear_dirs_matchfd_reference_away_fromzero_psi() {
2230        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
2231        let w = Array1::<f64>::ones(y.len());
2232        let x = array![
2233            [1.0, -1.2, 0.3],
2234            [1.0, -0.8, -0.4],
2235            [1.0, -0.3, 0.7],
2236            [1.0, 0.1, -0.9],
2237            [1.0, 0.5, 0.2],
2238            [1.0, 0.9, -0.1],
2239            [1.0, 1.3, 0.8],
2240            [1.0, 1.7, -0.6],
2241        ];
2242        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
2243        let cfg =
2244            RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false).with_max_iterations(500);
2245        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2246        let rho = array![0.0];
2247        let psi = array![0.7, -0.4];
2248        let theta = array![rho[0], psi[0], psi[1]];
2249        let hyper_dirs = vec![
2250            DirectionalHyperParam::single_penalty(
2251                0,
2252                Array2::<f64>::zeros((x.nrows(), x.ncols())),
2253                array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15],],
2254                None,
2255                None,
2256            )
2257            .expect("linear tau direction"),
2258            DirectionalHyperParam::single_penalty(
2259                0,
2260                Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2261                Array2::<f64>::zeros((x.ncols(), x.ncols())),
2262                None,
2263                None,
2264            )
2265            .expect("linear tau direction"),
2266        ];
2267
2268        let (_, _, h_full) =
2269            compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2270                .expect("joint hyper cost+gradient+hessian");
2271        let h_tt_analytic = h_full.slice(s![rho.len().., rho.len()..]).to_owned();
2272
2273        // FD via physical perturbation of design/penalty matrices (matching
2274        // the V_tau FD pattern).  For column j we perturb X and S₀ along
2275        // direction j, build fresh states, and evaluate the τ-gradient for
2276        // every direction i at those perturbed states.
2277        let x_tau_mats: Vec<Array2<f64>> = vec![
2278            Array2::<f64>::zeros((x.nrows(), x.ncols())),
2279            Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2280        ];
2281        let s_tau_mats: Vec<Array2<f64>> = vec![
2282            array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15]],
2283            Array2::<f64>::zeros((x.ncols(), x.ncols())),
2284        ];
2285
2286        let h_ttfd = directional_tau_hessian_fd_reference(
2287            &y,
2288            &w,
2289            &x,
2290            &s0,
2291            &cfg,
2292            &rho,
2293            &hyper_dirs,
2294            &x_tau_mats,
2295            &s_tau_mats,
2296        );
2297
2298        let num = (&h_tt_analytic - &h_ttfd)
2299            .iter()
2300            .map(|v| v * v)
2301            .sum::<f64>()
2302            .sqrt();
2303        let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2304        let rel = num / den;
2305        assert!(
2306            rel < 1e-4,
2307            "linear-dir joint tau-tau block deviates from FD reference away from zero psi: rel={rel:.3e}, analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2308        );
2309    }
2310
2311    #[test]
2312    pub(crate) fn joint_hypervalidation_rejects_out_of_boundssecond_order_penalty_index() {
2313        // The hyper direction declares a second-order penalty derivative
2314        // against base penalty index 1, but the configured ρ vector has
2315        // dimension 1 (so only index 0 is valid).  The pair-callback
2316        // builder in `build_tau_penalty_derivative_data` is responsible for
2317        // validating both first- and second-order penalty indices against
2318        // `rho.len()`; this test pins that contract.
2319        //
2320        // We deliberately keep `firth_bias_reduction = true` here so the
2321        // call site exercises the full Firth/Tierney–Kadane outer pipeline:
2322        // PIRLS + ext-coord construction + pair-callback assembly.  With
2323        // analytic c/d propagation now wired in
2324        // `tk_direct_gradient_from_cd_and_design`, there is no longer any
2325        // FD-fallback rejection on this path, so the out-of-bounds error
2326        // fired by the pair-callback builder is the first failure the
2327        // joint evaluator surfaces — and that is exactly what we want this
2328        // test to assert.
2329        let y = array![0.0, 1.0, 0.0, 1.0];
2330        let w = Array1::<f64>::ones(y.len());
2331        let x = array![
2332            [1.0, -0.5, 0.2],
2333            [1.0, -0.1, -0.3],
2334            [1.0, 0.4, 0.6],
2335            [1.0, 0.9, -0.2],
2336        ];
2337        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2338        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, true);
2339        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2340        let theta = array![0.0, 0.0];
2341        let hyper_dirs = vec![
2342            DirectionalHyperParam::new(
2343                Array2::<f64>::zeros((x.nrows(), x.ncols())),
2344                vec![(0, Array2::<f64>::zeros((x.ncols(), x.ncols())))],
2345                None,
2346                Some(vec![Some(vec![(1, Array2::<f64>::eye(x.ncols()))])]),
2347            )
2348            .expect("hyper direction with invalid second-order penalty index"),
2349        ];
2350
2351        let msg = match compute_joint_hypercostgradienthessian(&state, &theta, 1, &hyper_dirs) {
2352            Ok(_) => panic!("invalid second-order penalty index should be rejected"),
2353            Err(err) => err.to_string(),
2354        };
2355        assert!(
2356            msg.contains("out of bounds") || msg.contains("penalty_index"),
2357            "unexpected validation error: {msg}"
2358        );
2359    }
2360
2361    #[test]
2362    pub(crate) fn joint_tau_tau_analytic_matchesfd_reference() {
2363        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
2364        let w = Array1::<f64>::ones(y.len());
2365        let x = array![
2366            [1.0, -1.2, 0.3],
2367            [1.0, -0.8, -0.4],
2368            [1.0, -0.3, 0.7],
2369            [1.0, 0.1, -0.9],
2370            [1.0, 0.5, 0.2],
2371            [1.0, 0.9, -0.1],
2372            [1.0, 1.3, 0.8],
2373            [1.0, 1.7, -0.6],
2374        ];
2375        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
2376        let cfg =
2377            RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false).with_max_iterations(500);
2378        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2379        let rho = array![0.0];
2380        let psi = array![0.0, 0.0];
2381        let hyper_dirs = vec![
2382            DirectionalHyperParam::single_penalty(
2383                0,
2384                Array2::<f64>::zeros((x.nrows(), x.ncols())),
2385                array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15],],
2386                None,
2387                None,
2388            )
2389            .expect("single-penalty hyper direction"),
2390            DirectionalHyperParam::single_penalty(
2391                0,
2392                Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2393                Array2::<f64>::zeros((x.ncols(), x.ncols())),
2394                None,
2395                None,
2396            )
2397            .expect("single-penalty hyper direction"),
2398        ];
2399
2400        let theta = {
2401            let mut t = Array1::<f64>::zeros(rho.len() + psi.len());
2402            t.slice_mut(s![..rho.len()]).assign(&rho);
2403            t.slice_mut(s![rho.len()..]).assign(&psi);
2404            t
2405        };
2406        let (_, _, h_full) =
2407            compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2408                .expect("joint hyper cost+gradient+hessian");
2409        let h_tt_analytic = h_full.slice(s![rho.len().., rho.len()..]).to_owned();
2410        assert_eq!(h_tt_analytic.nrows(), hyper_dirs.len());
2411        assert_eq!(h_tt_analytic.ncols(), hyper_dirs.len());
2412
2413        // FD via physical perturbation of design/penalty matrices (matching
2414        // the V_tau FD pattern).  For column j we perturb X and S₀ along
2415        // direction j, build fresh states, and evaluate the τ-gradient for
2416        // every direction i at those perturbed states.
2417        let x_tau_mats: Vec<Array2<f64>> = vec![
2418            Array2::<f64>::zeros((x.nrows(), x.ncols())),
2419            Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2420        ];
2421        let s_tau_mats: Vec<Array2<f64>> = vec![
2422            array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15]],
2423            Array2::<f64>::zeros((x.ncols(), x.ncols())),
2424        ];
2425
2426        let h_ttfd = directional_tau_hessian_fd_reference(
2427            &y,
2428            &w,
2429            &x,
2430            &s0,
2431            &cfg,
2432            &rho,
2433            &hyper_dirs,
2434            &x_tau_mats,
2435            &s_tau_mats,
2436        );
2437
2438        let num = (&h_tt_analytic - &h_ttfd)
2439            .iter()
2440            .map(|v| v * v)
2441            .sum::<f64>()
2442            .sqrt();
2443        let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2444        let rel = num / den;
2445        assert!(
2446            rel < 1e-4,
2447            "analytic tau-tau block deviates from FD reference: rel={rel:.3e}, analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2448        );
2449    }
2450
2451    // ── Profiled Gaussian REML coverage for design-moving τ-directions ──
2452    //
2453    // The existing directional-hyper tests all use BinomialLogit, which has
2454    // DispersionHandling::Fixed.  These tests validate the profiled Gaussian
2455    // path (DispersionHandling::ProfiledGaussian) with design-moving
2456    // τ-directions, where the profiled scale φ̂ = D_p/(n−M) depends on ρ
2457    // and the envelope-theorem rescaling by (n−M)/D_p must be correct.
2458
2459    /// Shared test fixture for profiled Gaussian REML tests.
2460    pub(crate) struct GaussianRemlFixture {
2461        pub(crate) y: Array1<f64>,
2462        pub(crate) w: Array1<f64>,
2463        pub(crate) x: Array2<f64>,
2464        pub(crate) s0: Array2<f64>,
2465        pub(crate) cfg: RemlConfig,
2466        pub(crate) rho: Array1<f64>,
2467        /// Design-moving τ-direction (non-zero X_τ, zero S_τ).
2468        pub(crate) x_tau_design: Array2<f64>,
2469        /// Penalty-only τ-direction (zero X_τ, non-zero S_τ).
2470        pub(crate) s_tau_penalty: Array2<f64>,
2471    }
2472
2473    impl GaussianRemlFixture {
2474        pub(crate) fn new() -> Self {
2475            let y = array![0.5, 1.2, -0.3, 0.8, 1.1, -0.6, 0.9, 0.1, -0.2, 0.7];
2476            let x = array![
2477                [1.0, -1.2, 0.3],
2478                [1.0, -0.8, -0.4],
2479                [1.0, -0.3, 0.7],
2480                [1.0, 0.1, -0.9],
2481                [1.0, 0.5, 0.2],
2482                [1.0, 0.9, -0.1],
2483                [1.0, 1.3, 0.8],
2484                [1.0, 1.7, -0.6],
2485                [1.0, -0.5, 0.5],
2486                [1.0, 0.3, -0.3],
2487            ];
2488            Self {
2489                w: Array1::<f64>::ones(y.len()),
2490                y,
2491                x: x.clone(),
2492                s0: array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9]],
2493                cfg: RemlConfig::external(gaussian_identity_glm_spec(), 1e-14, false),
2494                rho: array![0.0],
2495                x_tau_design: array![
2496                    [0.0, 1e-3, -2e-3],
2497                    [0.0, -3e-3, 1e-3],
2498                    [0.0, 2e-3, 0.5e-3],
2499                    [0.0, -1e-3, 3e-3],
2500                    [0.0, 0.5e-3, -1e-3],
2501                    [0.0, 1.5e-3, 2e-3],
2502                    [0.0, -2e-3, -0.5e-3],
2503                    [0.0, 3e-3, 1e-3],
2504                    [0.0, -0.5e-3, 2e-3],
2505                    [0.0, 1e-3, -1.5e-3],
2506                ],
2507                s_tau_penalty: array![[0.0, 0.0, 0.0], [0.0, 0.25, 0.04], [0.0, 0.04, 0.15]],
2508            }
2509        }
2510    }
2511
2512    impl LogitDesignMotionFixture for GaussianRemlFixture {
2513        fn y(&self) -> &Array1<f64> {
2514            &self.y
2515        }
2516        fn w(&self) -> &Array1<f64> {
2517            &self.w
2518        }
2519        fn x(&self) -> &Array2<f64> {
2520            &self.x
2521        }
2522        fn s0(&self) -> &Array2<f64> {
2523            &self.s0
2524        }
2525        fn cfg(&self) -> &RemlConfig {
2526            &self.cfg
2527        }
2528        fn rho(&self) -> &Array1<f64> {
2529            &self.rho
2530        }
2531    }
2532
2533    #[test]
2534    pub(crate) fn profiled_gaussian_design_moving_gradient_matches_fd() {
2535        let f = GaussianRemlFixture::new();
2536        let state = f.state();
2537        let s_tau = Array2::<f64>::zeros((3, 3));
2538        let hyper = DirectionalHyperParam::single_penalty(
2539            0,
2540            f.x_tau_design.clone(),
2541            s_tau.clone(),
2542            None,
2543            None,
2544        )
2545        .expect("design-moving hyper direction");
2546
2547        let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2548            .expect("analytic directional gradient");
2549        let v_taufd = f.fd_directional_gradient(&f.x_tau_design, &s_tau);
2550
2551        let v_rel = (v_tau_analytic - v_taufd).abs() / v_taufd.abs().max(1e-10);
2552        assert!(
2553            v_rel < 1e-3,
2554            "Gaussian REML design-moving V_tau mismatch: rel={v_rel:.3e}, \
2555             analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
2556        );
2557    }
2558
2559    #[test]
2560    pub(crate) fn profiled_gaussian_penalty_only_gradient_matches_fd() {
2561        let f = GaussianRemlFixture::new();
2562        let state = f.state();
2563        let x_tau = Array2::<f64>::zeros(f.x.raw_dim());
2564        let hyper = DirectionalHyperParam::single_penalty(
2565            0,
2566            x_tau.clone(),
2567            f.s_tau_penalty.clone(),
2568            None,
2569            None,
2570        )
2571        .expect("penalty-only hyper direction");
2572
2573        let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2574            .expect("analytic directional gradient");
2575        let v_taufd = f.fd_directional_gradient(&x_tau, &f.s_tau_penalty);
2576
2577        let v_rel = (v_tau_analytic - v_taufd).abs() / v_taufd.abs().max(1e-10);
2578        assert!(
2579            v_rel < 1e-3,
2580            "Gaussian REML penalty-only V_tau mismatch: rel={v_rel:.3e}, \
2581             analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
2582        );
2583    }
2584
2585    #[test]
2586    pub(crate) fn profiled_gaussian_joint_hessian_matches_fd() {
2587        // Validate the ττ Hessian block under profiled Gaussian REML with
2588        // both a penalty-only and a design-moving direction.
2589        let f = GaussianRemlFixture::new();
2590        let x_tau_0 = Array2::<f64>::zeros(f.x.raw_dim());
2591        let s_tau_0 = f.s_tau_penalty.clone();
2592        let x_tau_1 = f.x_tau_design.clone();
2593        let s_tau_1 = Array2::<f64>::zeros((3, 3));
2594
2595        let hyper_dirs = vec![
2596            DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
2597                .expect("penalty-only direction"),
2598            DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
2599                .expect("design-moving direction"),
2600        ];
2601
2602        let state = f.state();
2603        let mut theta = Array1::<f64>::zeros(f.rho.len() + hyper_dirs.len());
2604        theta.slice_mut(s![..f.rho.len()]).assign(&f.rho);
2605        let (_, _, h_full) =
2606            compute_joint_hypercostgradienthessian(&state, &theta, f.rho.len(), &hyper_dirs)
2607                .expect("joint cost+gradient+hessian");
2608        let h_tt_analytic = h_full.slice(s![f.rho.len().., f.rho.len()..]).to_owned();
2609
2610        // Finite-difference Hessian: perturb each direction, re-evaluate
2611        // gradient of all directions at perturbed states.
2612        let x_tau_mats = vec![x_tau_0.clone(), x_tau_1.clone()];
2613        let s_tau_mats = vec![s_tau_0.clone(), s_tau_1.clone()];
2614        let h_ttfd = directional_tau_hessian_fd_reference(
2615            &f.y,
2616            &f.w,
2617            &f.x,
2618            &f.s0,
2619            &f.cfg,
2620            &f.rho,
2621            &hyper_dirs,
2622            &x_tau_mats,
2623            &s_tau_mats,
2624        );
2625
2626        let num = (&h_tt_analytic - &h_ttfd)
2627            .iter()
2628            .map(|v| v * v)
2629            .sum::<f64>()
2630            .sqrt();
2631        let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2632        let rel = num / den;
2633        assert!(
2634            rel < 1e-4,
2635            "Gaussian REML tau-tau Hessian mismatch: rel={rel:.3e}, \
2636             analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2637        );
2638    }
2639
2640    // ── Non-Gaussian + design-motion: IFT Hessian-drift coverage ────────
2641    //
2642    // For non-Gaussian links (logit, probit, cloglog, ...), H = X'W(η)X + S
2643    // depends on β̂ through η = Xβ̂.  When ψ moves the design, the total
2644    // Hessian drift dH/dψ includes an IFT contribution from dβ̂/dψ:
2645    //
2646    //   dH/dψ = [explicit at fixed β] + X' diag(c ⊙ X(-v_i)) X
2647    //
2648    // where v_i = H⁻¹ g_i.  The standard GLM path handles this via
2649    // `hessian_derivative_correction(v_i)`.  This test validates that the
2650    // gradient is correct for logit + design-moving ψ, which would fail if
2651    // the IFT correction were missing.
2652
2653    #[test]
2654    pub(crate) fn logit_design_moving_gradient_matches_fd() {
2655        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
2656        let w = Array1::<f64>::ones(y.len());
2657        let x = array![
2658            [1.0, -1.2, 0.3],
2659            [1.0, -0.8, -0.4],
2660            [1.0, -0.3, 0.7],
2661            [1.0, 0.1, -0.9],
2662            [1.0, 0.5, 0.2],
2663            [1.0, 0.9, -0.1],
2664            [1.0, 1.3, 0.8],
2665            [1.0, 1.7, -0.6],
2666            [1.0, -0.5, 0.5],
2667            [1.0, 0.3, -0.3],
2668        ];
2669        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9]];
2670        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
2671        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2672        let rho = array![0.0];
2673
2674        // Design-moving direction with non-zero X_τ.
2675        let x_tau = array![
2676            [0.0, 1e-3, -2e-3],
2677            [0.0, -3e-3, 1e-3],
2678            [0.0, 2e-3, 0.5e-3],
2679            [0.0, -1e-3, 3e-3],
2680            [0.0, 0.5e-3, -1e-3],
2681            [0.0, 1.5e-3, 2e-3],
2682            [0.0, -2e-3, -0.5e-3],
2683            [0.0, 3e-3, 1e-3],
2684            [0.0, -0.5e-3, 2e-3],
2685            [0.0, 1e-3, -1.5e-3],
2686        ];
2687        let s_tau = Array2::<f64>::zeros((3, 3));
2688        let hyper =
2689            DirectionalHyperParam::single_penalty(0, x_tau.clone(), s_tau.clone(), None, None)
2690                .expect("design-moving hyper direction");
2691
2692        let v_tau_analytic = single_directional_tau_gradient(&state, &rho, hyper)
2693            .expect("analytic directional gradient");
2694
2695        let h = 2e-5;
2696        let x_plus = &x + &x_tau.mapv(|v| h * v);
2697        let x_minus = &x - &x_tau.mapv(|v| h * v);
2698        let state_plus = build_logit_state(&y, &w, &x_plus, &s0, &cfg);
2699        let state_minus = build_logit_state(&y, &w, &x_minus, &s0, &cfg);
2700        let v_plus = state_plus.compute_cost(&rho).expect("cost+");
2701        let v_minus = state_minus.compute_cost(&rho).expect("cost-");
2702        let v_taufd = (v_plus - v_minus) / (2.0 * h);
2703
2704        let v_rel = (v_tau_analytic - v_taufd).abs() / v_taufd.abs().max(1e-10);
2705        assert!(
2706            v_rel < 1e-3,
2707            "Logit REML design-moving V_tau mismatch: rel={v_rel:.3e}, \
2708             analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
2709        );
2710    }
2711
2712    #[test]
2713    pub(crate) fn logit_design_moving_hessian_matches_fd() {
2714        // Hessian-level validation for logit + design-motion.
2715        // The IFT correction enters the trace term through
2716        // hessian_derivative_correction(v_i), so the Hessian is the most
2717        // sensitive test of whether the correction is applied correctly.
2718        let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
2719        let w = Array1::<f64>::ones(y.len());
2720        let x = array![
2721            [1.0, -1.2, 0.3],
2722            [1.0, -0.8, -0.4],
2723            [1.0, -0.3, 0.7],
2724            [1.0, 0.1, -0.9],
2725            [1.0, 0.5, 0.2],
2726            [1.0, 0.9, -0.1],
2727            [1.0, 1.3, 0.8],
2728            [1.0, 1.7, -0.6],
2729            [1.0, -0.5, 0.5],
2730            [1.0, 0.3, -0.3],
2731        ];
2732        let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9]];
2733        let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
2734        let rho = array![0.0];
2735
2736        // Two directions: one penalty-only, one design-moving.
2737        let x_tau_0 = Array2::<f64>::zeros(x.raw_dim());
2738        let s_tau_0 = array![[0.0, 0.0, 0.0], [0.0, 0.25, 0.04], [0.0, 0.04, 0.15]];
2739        let x_tau_1 = array![
2740            [0.0, 1e-3, -2e-3],
2741            [0.0, -3e-3, 1e-3],
2742            [0.0, 2e-3, 0.5e-3],
2743            [0.0, -1e-3, 3e-3],
2744            [0.0, 0.5e-3, -1e-3],
2745            [0.0, 1.5e-3, 2e-3],
2746            [0.0, -2e-3, -0.5e-3],
2747            [0.0, 3e-3, 1e-3],
2748            [0.0, -0.5e-3, 2e-3],
2749            [0.0, 1e-3, -1.5e-3],
2750        ];
2751        let s_tau_1 = Array2::<f64>::zeros((3, 3));
2752
2753        let hyper_dirs = vec![
2754            DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
2755                .expect("penalty-only direction"),
2756            DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
2757                .expect("design-moving direction"),
2758        ];
2759
2760        let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2761        let mut theta = Array1::<f64>::zeros(rho.len() + hyper_dirs.len());
2762        theta.slice_mut(s![..rho.len()]).assign(&rho);
2763        let (_, _, h_full) =
2764            compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2765                .expect("joint cost+gradient+hessian");
2766        let h_tt_analytic = h_full.slice(s![rho.len().., rho.len()..]).to_owned();
2767
2768        let x_tau_mats = vec![x_tau_0.clone(), x_tau_1.clone()];
2769        let s_tau_mats = vec![s_tau_0.clone(), s_tau_1.clone()];
2770        let h_ttfd = directional_tau_hessian_fd_reference(
2771            &y,
2772            &w,
2773            &x,
2774            &s0,
2775            &cfg,
2776            &rho,
2777            &hyper_dirs,
2778            &x_tau_mats,
2779            &s_tau_mats,
2780        );
2781
2782        let num = (&h_tt_analytic - &h_ttfd)
2783            .iter()
2784            .map(|v| v * v)
2785            .sum::<f64>()
2786            .sqrt();
2787        let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2788        let rel = num / den;
2789        assert!(
2790            rel < 1e-4,
2791            "Logit REML design-moving tau-tau Hessian mismatch: rel={rel:.3e}, \
2792             analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2793        );
2794    }
2795
2796    // ── Larger non-Gaussian + design-motion fixture (n=30, p=5) ────────
2797    //
2798    // Validates the IFT correction (hessian_derivative_correction) at a
2799    // scale large enough that the correction is numerically non-trivial:
2800    // with n=30 and p=5, the logistic Hessian W(η) is far from identity
2801    // and the IFT term dβ̂/dψ contributes meaningfully.
2802
2803    /// Shared test fixture for binomial-logit REML with design-moving
2804    /// ψ-coordinates, n=30, p=5.
2805    pub(crate) struct BinomialLogitDesignMotionFixture {
2806        pub(crate) y: Array1<f64>,
2807        pub(crate) w: Array1<f64>,
2808        pub(crate) x: Array2<f64>,
2809        pub(crate) s0: Array2<f64>,
2810        pub(crate) cfg: RemlConfig,
2811        pub(crate) rho: Array1<f64>,
2812        /// Design-moving τ-direction: non-zero X_τ, zero S_τ.
2813        pub(crate) x_tau_design: Array2<f64>,
2814        /// Penalty-only τ-direction: zero X_τ, non-zero S_τ.
2815        pub(crate) s_tau_penalty: Array2<f64>,
2816    }
2817
2818    impl BinomialLogitDesignMotionFixture {
2819        pub(crate) fn new() -> Self {
2820            // Binary response with roughly balanced classes.
2821            let y = array![
2822                1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0,
2823                1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0
2824            ];
2825            // Design matrix: intercept + 4 covariate columns with varied magnitudes.
2826            let x = array![
2827                [1.0, -1.50, 0.42, 0.88, -0.31],
2828                [1.0, -1.12, -0.65, 0.14, 1.23],
2829                [1.0, -0.80, 1.10, -0.53, 0.07],
2830                [1.0, -0.55, -0.22, 1.40, -0.90],
2831                [1.0, -0.30, 0.73, -1.05, 0.44],
2832                [1.0, -0.05, -1.33, 0.60, 0.81],
2833                [1.0, 0.18, 0.55, -0.27, -1.15],
2834                [1.0, 0.42, -0.90, 1.12, 0.33],
2835                [1.0, 0.70, 1.28, -0.78, -0.56],
2836                [1.0, 0.95, -0.18, 0.45, 1.40],
2837                [1.0, 1.20, 0.66, -1.30, -0.02],
2838                [1.0, 1.45, -1.05, 0.22, 0.68],
2839                [1.0, -1.35, 0.90, 0.55, -0.43],
2840                [1.0, -0.98, -0.40, -0.88, 1.05],
2841                [1.0, -0.62, 1.42, 0.30, -0.70],
2842                [1.0, -0.28, -0.77, -1.18, 0.52],
2843                [1.0, 0.05, 0.15, 0.95, -1.35],
2844                [1.0, 0.33, -1.20, -0.40, 0.18],
2845                [1.0, 0.60, 0.82, 1.25, -0.85],
2846                [1.0, 0.88, -0.50, -0.65, 1.10],
2847                [1.0, 1.15, 1.05, 0.10, -0.22],
2848                [1.0, -1.22, -0.95, 0.72, 0.90],
2849                [1.0, -0.75, 0.38, -1.42, 0.15],
2850                [1.0, -0.42, -1.15, 0.50, -1.08],
2851                [1.0, -0.10, 0.60, -0.15, 0.75],
2852                [1.0, 0.25, -0.28, 1.05, -0.48],
2853                [1.0, 0.52, 1.35, -0.92, 0.30],
2854                [1.0, 0.80, -0.70, 0.38, 1.20],
2855                [1.0, 1.08, 0.48, -0.60, -0.95],
2856                [1.0, 1.35, -0.55, 0.85, 0.42]
2857            ];
2858            // Penalty matrix: zero on intercept, SPD on remaining 4 columns.
2859            let s0 = array![
2860                [0.0, 0.0, 0.0, 0.0, 0.0],
2861                [0.0, 1.40, 0.15, 0.05, -0.10],
2862                [0.0, 0.15, 1.10, -0.20, 0.08],
2863                [0.0, 0.05, -0.20, 0.95, 0.12],
2864                [0.0, -0.10, 0.08, 0.12, 1.25]
2865            ];
2866            let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
2867            // Design-moving direction: perturb covariate columns, leave
2868            // intercept untouched.
2869            let x_tau_design = array![
2870                [0.0, 1.2e-3, -0.8e-3, 0.5e-3, -1.5e-3],
2871                [0.0, -2.0e-3, 1.4e-3, -0.3e-3, 0.9e-3],
2872                [0.0, 0.6e-3, -1.1e-3, 1.8e-3, -0.4e-3],
2873                [0.0, -1.3e-3, 0.7e-3, -1.0e-3, 2.1e-3],
2874                [0.0, 0.9e-3, -0.5e-3, 0.2e-3, -0.8e-3],
2875                [0.0, -0.4e-3, 1.8e-3, -1.5e-3, 0.3e-3],
2876                [0.0, 1.5e-3, -1.3e-3, 0.8e-3, -1.1e-3],
2877                [0.0, -0.7e-3, 0.4e-3, -2.0e-3, 1.6e-3],
2878                [0.0, 2.2e-3, -0.9e-3, 1.3e-3, -0.6e-3],
2879                [0.0, -1.0e-3, 1.6e-3, -0.7e-3, 0.5e-3],
2880                [0.0, 0.3e-3, -2.1e-3, 1.1e-3, -1.8e-3],
2881                [0.0, -1.8e-3, 0.2e-3, -0.4e-3, 1.3e-3],
2882                [0.0, 1.1e-3, -1.5e-3, 2.0e-3, -0.2e-3],
2883                [0.0, -0.5e-3, 0.9e-3, -1.2e-3, 0.7e-3],
2884                [0.0, 1.7e-3, -0.3e-3, 0.6e-3, -2.0e-3],
2885                [0.0, -1.4e-3, 1.1e-3, -0.9e-3, 0.4e-3],
2886                [0.0, 0.8e-3, -1.7e-3, 1.5e-3, -0.1e-3],
2887                [0.0, -0.2e-3, 0.6e-3, -1.8e-3, 1.0e-3],
2888                [0.0, 1.4e-3, -0.4e-3, 0.3e-3, -1.3e-3],
2889                [0.0, -0.9e-3, 2.0e-3, -0.5e-3, 0.8e-3],
2890                [0.0, 0.5e-3, -1.0e-3, 1.6e-3, -0.7e-3],
2891                [0.0, -2.1e-3, 0.3e-3, -0.8e-3, 1.5e-3],
2892                [0.0, 0.7e-3, -1.8e-3, 0.9e-3, -0.3e-3],
2893                [0.0, -0.6e-3, 1.3e-3, -2.2e-3, 1.1e-3],
2894                [0.0, 1.9e-3, -0.7e-3, 0.4e-3, -0.9e-3],
2895                [0.0, -1.1e-3, 0.5e-3, -1.4e-3, 2.2e-3],
2896                [0.0, 0.4e-3, -1.6e-3, 1.2e-3, -0.5e-3],
2897                [0.0, -1.6e-3, 0.8e-3, -0.1e-3, 0.6e-3],
2898                [0.0, 1.3e-3, -2.2e-3, 0.7e-3, -1.4e-3],
2899                [0.0, -0.3e-3, 1.0e-3, -1.6e-3, 1.8e-3]
2900            ];
2901            // Penalty-only direction: non-zero S_τ, symmetric, zero on intercept.
2902            let s_tau_penalty = array![
2903                [0.0, 0.0, 0.0, 0.0, 0.0],
2904                [0.0, 0.30, 0.05, -0.02, 0.04],
2905                [0.0, 0.05, 0.22, 0.03, -0.01],
2906                [0.0, -0.02, 0.03, 0.18, 0.06],
2907                [0.0, 0.04, -0.01, 0.06, 0.26]
2908            ];
2909            Self {
2910                w: Array1::<f64>::ones(y.len()),
2911                y,
2912                x,
2913                s0,
2914                cfg,
2915                rho: array![0.0],
2916                x_tau_design,
2917                s_tau_penalty,
2918            }
2919        }
2920    }
2921
2922    impl LogitDesignMotionFixture for BinomialLogitDesignMotionFixture {
2923        fn y(&self) -> &Array1<f64> {
2924            &self.y
2925        }
2926        fn w(&self) -> &Array1<f64> {
2927            &self.w
2928        }
2929        fn x(&self) -> &Array2<f64> {
2930            &self.x
2931        }
2932        fn s0(&self) -> &Array2<f64> {
2933            &self.s0
2934        }
2935        fn cfg(&self) -> &RemlConfig {
2936            &self.cfg
2937        }
2938        fn rho(&self) -> &Array1<f64> {
2939            &self.rho
2940        }
2941    }
2942
2943    // ── n=30, p=5 binomial-logit design-motion gradient tests ────────
2944
2945    #[test]
2946    pub(crate) fn binomial_logit_n30_design_moving_gradient_matches_fd() {
2947        // Pure design-motion: X_τ ≠ 0, S_τ = 0.
2948        // The IFT correction is essential here: because the family is
2949        // binomial-logit, the working weights W(η) depend on β̂, so
2950        // when X moves with ψ, the implicit derivative dβ̂/dψ enters
2951        // the total Hessian drift.  Without hessian_derivative_correction
2952        // the analytic gradient would disagree with FD.
2953        let f = BinomialLogitDesignMotionFixture::new();
2954        let state = f.state();
2955        let s_tau = Array2::<f64>::zeros((5, 5));
2956        let hyper = DirectionalHyperParam::single_penalty(
2957            0,
2958            f.x_tau_design.clone(),
2959            s_tau.clone(),
2960            None,
2961            None,
2962        )
2963        .expect("design-moving hyper direction");
2964
2965        let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2966            .expect("analytic directional gradient");
2967        let v_tau_fd = f.fd_directional_gradient(&f.x_tau_design, &s_tau);
2968
2969        let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
2970        assert!(
2971            v_rel < 1e-3,
2972            "Binomial-logit n=30 design-moving gradient mismatch: rel={v_rel:.3e}, \
2973             analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
2974        );
2975    }
2976
2977    #[test]
2978    pub(crate) fn binomial_logit_n30_penalty_only_gradient_matches_fd() {
2979        // Penalty-only direction: X_τ = 0, S_τ ≠ 0.
2980        // Serves as a baseline: the IFT correction should still be
2981        // present (since H depends on β̂ through W(η)), but the
2982        // explicit X_τ contribution is zero.
2983        let f = BinomialLogitDesignMotionFixture::new();
2984        let state = f.state();
2985        let x_tau = Array2::<f64>::zeros(f.x.raw_dim());
2986        let hyper = DirectionalHyperParam::single_penalty(
2987            0,
2988            x_tau.clone(),
2989            f.s_tau_penalty.clone(),
2990            None,
2991            None,
2992        )
2993        .expect("penalty-only hyper direction");
2994
2995        let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2996            .expect("analytic directional gradient");
2997        let v_tau_fd = f.fd_directional_gradient(&x_tau, &f.s_tau_penalty);
2998
2999        let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3000        assert!(
3001            v_rel < 1e-3,
3002            "Binomial-logit n=30 penalty-only gradient mismatch: rel={v_rel:.3e}, \
3003             analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3004        );
3005    }
3006
3007    #[test]
3008    pub(crate) fn binomial_logit_n30_joint_design_penalty_gradient_matches_fd() {
3009        // Joint direction: both X_τ ≠ 0 and S_τ ≠ 0 simultaneously.
3010        // This is the hardest case: the analytic gradient must correctly
3011        // combine the explicit penalty drift, the explicit design drift,
3012        // and the IFT Hessian-drift correction.
3013        let f = BinomialLogitDesignMotionFixture::new();
3014        let state = f.state();
3015        let hyper = DirectionalHyperParam::single_penalty(
3016            0,
3017            f.x_tau_design.clone(),
3018            f.s_tau_penalty.clone(),
3019            None,
3020            None,
3021        )
3022        .expect("joint design+penalty hyper direction");
3023
3024        let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
3025            .expect("analytic directional gradient");
3026        let v_tau_fd = f.fd_directional_gradient(&f.x_tau_design, &f.s_tau_penalty);
3027
3028        let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3029        assert!(
3030            v_rel < 1e-3,
3031            "Binomial-logit n=30 joint design+penalty gradient mismatch: rel={v_rel:.3e}, \
3032             analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3033        );
3034    }
3035
3036    #[test]
3037    pub(crate) fn binomial_logit_n30_design_moving_hessian_matches_fd() {
3038        // Hessian-level validation with two τ-directions: one
3039        // penalty-only and one design-moving.  The ττ Hessian block is
3040        // the most sensitive test of the IFT correction because errors
3041        // in the correction accumulate quadratically in the trace term.
3042        let f = BinomialLogitDesignMotionFixture::new();
3043        let x_tau_0 = Array2::<f64>::zeros(f.x.raw_dim());
3044        let s_tau_0 = f.s_tau_penalty.clone();
3045        let x_tau_1 = f.x_tau_design.clone();
3046        let s_tau_1 = Array2::<f64>::zeros((5, 5));
3047
3048        let hyper_dirs = vec![
3049            DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
3050                .expect("penalty-only direction"),
3051            DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
3052                .expect("design-moving direction"),
3053        ];
3054
3055        let state = f.state();
3056        let mut theta = Array1::<f64>::zeros(f.rho.len() + hyper_dirs.len());
3057        theta.slice_mut(s![..f.rho.len()]).assign(&f.rho);
3058        let (_, _, h_full) =
3059            compute_joint_hypercostgradienthessian(&state, &theta, f.rho.len(), &hyper_dirs)
3060                .expect("joint cost+gradient+hessian");
3061        let h_tt_analytic = h_full.slice(s![f.rho.len().., f.rho.len()..]).to_owned();
3062
3063        let x_tau_mats = vec![x_tau_0.clone(), x_tau_1.clone()];
3064        let s_tau_mats = vec![s_tau_0.clone(), s_tau_1.clone()];
3065        let h_tt_fd = directional_tau_hessian_fd_reference(
3066            &f.y,
3067            &f.w,
3068            &f.x,
3069            &f.s0,
3070            &f.cfg,
3071            &f.rho,
3072            &hyper_dirs,
3073            &x_tau_mats,
3074            &s_tau_mats,
3075        );
3076
3077        let num = (&h_tt_analytic - &h_tt_fd)
3078            .iter()
3079            .map(|v| v * v)
3080            .sum::<f64>()
3081            .sqrt();
3082        let den = h_tt_fd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
3083        let rel = num / den;
3084        assert!(
3085            rel < 1e-4,
3086            "Binomial-logit n=30 tau-tau Hessian mismatch: rel={rel:.3e}, \
3087             analytic={h_tt_analytic:?}, fd={h_tt_fd:?}"
3088        );
3089    }
3090
3091    #[test]
3092    pub(crate) fn binomial_logit_n30_nonzero_rho_design_moving_gradient_matches_fd() {
3093        // Validate at a non-trivial smoothing parameter ρ = log(λ) = 1.5,
3094        // so the penalty term λS is scaled up and the balance between
3095        // likelihood and penalty is different from ρ=0.
3096        let f = BinomialLogitDesignMotionFixture::new();
3097        let rho = array![1.5];
3098        let s_tau = Array2::<f64>::zeros((5, 5));
3099
3100        let state = f.state();
3101        let hyper = DirectionalHyperParam::single_penalty(
3102            0,
3103            f.x_tau_design.clone(),
3104            s_tau.clone(),
3105            None,
3106            None,
3107        )
3108        .expect("design-moving hyper direction");
3109
3110        let v_tau_analytic = single_directional_tau_gradient(&state, &rho, hyper)
3111            .expect("analytic directional gradient");
3112
3113        // FD at the shifted ρ: perturb X, re-solve inner, evaluate cost.
3114        let h = 2e-5;
3115        let (state_plus, state_minus) = f.state_perturbed(&f.x_tau_design, &s_tau, h);
3116        let v_plus = state_plus.compute_cost(&rho).expect("cost+");
3117        let v_minus = state_minus.compute_cost(&rho).expect("cost-");
3118        let v_tau_fd = (v_plus - v_minus) / (2.0 * h);
3119
3120        let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3121        assert!(
3122            v_rel < 1e-3,
3123            "Binomial-logit n=30 rho=1.5 design-moving gradient mismatch: rel={v_rel:.3e}, \
3124             analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3125        );
3126    }
3127
3128    #[test]
3129    pub(crate) fn binomial_logit_n30_rank_deficient_hessian_matches_cost_fd() {
3130        // Regression lock for the `PenaltySubspaceTrace` pseudo-logdet
3131        // kernel installed by the rank-deficient LAML fix (see
3132        // `PenaltySubspaceTrace` and `intrinsic_hessian_pseudo_logdet_parts`;
3133        // since #901 the cost is the intrinsic `½ log|H_pen|₊` and the kernel
3134        // is the spectral `H_pen⁺`, exact for every drift direction).
3135        //
3136        // The sibling `binomial_logit_n30_design_moving_hessian_matches_fd`
3137        // passes pre- AND post-fix because its FD reference differentiates
3138        // the *analytic gradient* — any self-consistent (if wrong) gradient
3139        // kernel gives a self-consistent Hessian under re-differentiation,
3140        // so that test cannot distinguish full-space from projected traces.
3141        // It passed under the buggy kernel because the same leakage entered
3142        // both sides of the ratio and cancelled.
3143        //
3144        // Here we FD-differentiate `compute_cost` TWICE and compare against
3145        // the analytic Hessian.  Central second differences expose every
3146        // disagreement between `½ log|U_Sᵀ H U_S|_+` (used by the cost) and
3147        // `½ tr(G_ε(H) · Ḣ)` / `−½ tr(G_ε Ḣ_i G_ε Ḣ_j)` (the full-space
3148        // traces that the gradient and Hessian used before the projection
3149        // fix).  Under the buggy kernel the IFT correction
3150        // `D_β H[v] = X' diag(c ⊙ X v) X` leaks onto `null(S)` — X's
3151        // all-ones intercept column sits there — and that leakage enters
3152        // the analytic Hessian but not the cost's projected logdet.
3153        //
3154        // Direction mix chosen to maximise the null-space leakage pathway:
3155        //   τ_0 = penalty-only (X_τ = 0, S_τ ≠ 0)  → v_0 = H⁻¹(−S_τ β̂) is
3156        //         concentrated in range(S_+), but `D_β H[v_0]` has rows and
3157        //         columns on the intercept because `X[:,0] = 1_n`.
3158        //   τ_1 = design-moving (X_τ ≠ 0 on non-intercept columns, S_τ = 0)
3159        //         → `v_1` also picks up the intercept via `X'WX_τβ̂`, and
3160        //         the base drift `X_τᵀWX + XᵀWX_τ` straddles range(S_+) /
3161        //         null(S).
3162        // Both pure directions AND the mixed partial load the Schur correction,
3163        // so any of the three entries can catch a regression.
3164        let f = BinomialLogitDesignMotionFixture::new();
3165        let x_tau_0 = Array2::<f64>::zeros(f.x.raw_dim());
3166        let s_tau_0 = f.s_tau_penalty.clone();
3167        let x_tau_1 = f.x_tau_design.clone();
3168        let s_tau_1 = Array2::<f64>::zeros((5, 5));
3169
3170        let hyper_dirs = vec![
3171            DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
3172                .expect("penalty-only direction"),
3173            DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
3174                .expect("design-moving direction"),
3175        ];
3176
3177        // Analytic Hessian block.
3178        let state = f.state();
3179        let mut theta = Array1::<f64>::zeros(f.rho.len() + hyper_dirs.len());
3180        theta.slice_mut(s![..f.rho.len()]).assign(&f.rho);
3181        let (_, _, h_full) =
3182            compute_joint_hypercostgradienthessian(&state, &theta, f.rho.len(), &hyper_dirs)
3183                .expect("joint cost+gradient+hessian");
3184        let h_tt_analytic = h_full.slice(s![f.rho.len().., f.rho.len()..]).to_owned();
3185
3186        // Cost-level FD reference.  Central second differences give O(h²)
3187        // accuracy; the step is sized so the physical perturbation on X / S
3188        // stays near `1e-5` (same scale as the gradient tests).
3189        const TARGET_PHYSICAL_STEP: f64 = 1e-5;
3190        let x_tau_mats = [&x_tau_0, &x_tau_1];
3191        let s_tau_mats = [&s_tau_0, &s_tau_1];
3192        let steps: [f64; 2] = {
3193            let mut steps = [0.0; 2];
3194            for (j, step) in steps.iter_mut().enumerate() {
3195                let scale = x_tau_mats[j]
3196                    .iter()
3197                    .chain(s_tau_mats[j].iter())
3198                    .fold(0.0_f64, |acc, value| acc.max(value.abs()));
3199                *step = if scale > 0.0 {
3200                    TARGET_PHYSICAL_STEP / scale
3201                } else {
3202                    TARGET_PHYSICAL_STEP
3203                };
3204            }
3205            steps
3206        };
3207
3208        // Evaluate `compute_cost` at `(a · τ_0, b · τ_1)` multipliers.
3209        let eval_cost = |a: f64, b: f64| -> f64 {
3210            let x_eval = &f.x
3211                + &x_tau_mats[0].mapv(|v| a * steps[0] * v)
3212                + &x_tau_mats[1].mapv(|v| b * steps[1] * v);
3213            let s_eval = &f.s0
3214                + &s_tau_mats[0].mapv(|v| a * steps[0] * v)
3215                + &s_tau_mats[1].mapv(|v| b * steps[1] * v);
3216            let st = build_logit_state(&f.y, &f.w, &x_eval, &s_eval, &f.cfg);
3217            st.compute_cost(&f.rho).expect("cost eval")
3218        };
3219
3220        let v_00 = eval_cost(0.0, 0.0);
3221        let v_p0 = eval_cost(1.0, 0.0);
3222        let v_m0 = eval_cost(-1.0, 0.0);
3223        let v_0p = eval_cost(0.0, 1.0);
3224        let v_0m = eval_cost(0.0, -1.0);
3225        let v_pp = eval_cost(1.0, 1.0);
3226        let v_pm = eval_cost(1.0, -1.0);
3227        let v_mp = eval_cost(-1.0, 1.0);
3228        let v_mm = eval_cost(-1.0, -1.0);
3229
3230        let h00_fd = (v_p0 - 2.0 * v_00 + v_m0) / (steps[0] * steps[0]);
3231        let h11_fd = (v_0p - 2.0 * v_00 + v_0m) / (steps[1] * steps[1]);
3232        let h01_fd = (v_pp - v_pm - v_mp + v_mm) / (4.0 * steps[0] * steps[1]);
3233
3234        let h_tt_fd = array![[h00_fd, h01_fd], [h01_fd, h11_fd]];
3235
3236        let num = (&h_tt_analytic - &h_tt_fd)
3237            .iter()
3238            .map(|v| v * v)
3239            .sum::<f64>()
3240            .sqrt();
3241        let den = h_tt_fd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
3242        let rel = num / den;
3243
3244        assert!(
3245            rel < 3e-3,
3246            "Binomial-logit n=30 rank-deficient Hessian vs cost-FD mismatch: rel={rel:.3e}, \
3247             analytic={h_tt_analytic:?}, fd={h_tt_fd:?}"
3248        );
3249    }
3250}
3251
3252#[derive(Clone, Copy, Debug)]
3253pub(crate) enum RemlGeometry {
3254    DenseSpectral,
3255    SparseExactSpd,
3256}
3257
3258trait PenalizedGeometry {
3259    fn backend_kind(&self) -> GeometryBackendKind;
3260}
3261
3262#[derive(Clone)]
3263pub(crate) enum DerivativeMatrixStorage {
3264    Dense(Array2<f64>),
3265    Zero(ZeroDerivativeMatrix),
3266    Embedded(EmbeddedDerivativeMatrix),
3267    Implicit(ImplicitDerivativeOp),
3268    LatentCoord(LatentCoordDerivativeOp),
3269}
3270
3271/// Mechanical surface every `DerivativeMatrixStorage` variant must expose so
3272/// the `HyperDesignDerivative` / `HyperPenaltyDerivative` wrappers can dispatch
3273/// with a single per-call `storage_dispatch!`. Each backend owns its variant's
3274/// substantive math; the wrappers contain only one-line routing.
3275///
3276/// `design_*` variants treat the backend as an X-style operator (rows index
3277/// data, columns index coefficients); `penalty_*` variants treat the backend
3278/// as a square `p×p` penalty in the global coefficient frame. The Embedded
3279/// case is the only variant whose two views genuinely differ (local rows vs
3280/// total_dim square), which is why the two role-specific methods both live in
3281/// one trait rather than two parallel traits.
3282trait DerivativeStorageBackend {
3283    fn resident_byte_count(&self) -> usize;
3284    fn design_nrows(&self) -> usize;
3285    fn design_ncols(&self) -> usize;
3286    fn penalty_dim(&self) -> usize;
3287    fn uses_implicit_storage(&self) -> bool;
3288    fn any_nonzero(&self) -> bool;
3289    fn materialize(&self) -> Array2<f64>;
3290    fn implicit_first_axis_info(
3291        &self,
3292    ) -> Option<(
3293        std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3294        usize,
3295    )>;
3296    fn implicit_axis_count_hint(&self) -> Option<usize>;
3297    fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError>;
3298    fn design_transpose_mul_original(
3299        &self,
3300        v: &Array1<f64>,
3301    ) -> Result<Array1<f64>, EstimationError>;
3302    fn design_transformed(
3303        &self,
3304        qs: &Array2<f64>,
3305        free_basis_opt: Option<&Array2<f64>>,
3306    ) -> Result<Array2<f64>, EstimationError>;
3307    /// Default materialises through `design_transformed` then `.dot(u)`;
3308    /// implicit/latent-coordinate backends override with a direct-operator
3309    /// path that skips the dense materialisation.
3310    fn design_transformed_forward_mul(
3311        &self,
3312        qs: &Array2<f64>,
3313        free_basis_opt: Option<&Array2<f64>>,
3314        u: &Array1<f64>,
3315    ) -> Result<Array1<f64>, EstimationError> {
3316        Ok(self.design_transformed(qs, free_basis_opt)?.dot(u))
3317    }
3318    /// Default materialises through `design_transformed` then `.t().dot(v)`;
3319    /// implicit/latent-coordinate backends override with a direct path.
3320    fn design_transformed_transpose_mul(
3321        &self,
3322        qs: &Array2<f64>,
3323        free_basis_opt: Option<&Array2<f64>>,
3324        v: &Array1<f64>,
3325    ) -> Result<Array1<f64>, EstimationError> {
3326        Ok(self.design_transformed(qs, free_basis_opt)?.t().dot(v))
3327    }
3328    fn penalty_transformed(
3329        &self,
3330        qs: &Array2<f64>,
3331        free_basis_opt: Option<&Array2<f64>>,
3332    ) -> Result<Array2<f64>, EstimationError>;
3333    fn penalty_scaled_add_to(
3334        &self,
3335        target: &mut Array2<f64>,
3336        amp: f64,
3337    ) -> Result<(), EstimationError>;
3338}
3339
3340/// Fans `expr` over the four `DerivativeMatrixStorage` variants in one place
3341/// so every wrapper method is a single dispatch line — the compiler enforces
3342/// exhaustiveness here, so adding a new variant produces one hard error at
3343/// this site rather than a silent miss in any of the (currently 16) ladders.
3344macro_rules! storage_dispatch {
3345    ($scrutinee:expr, $backend:ident => $body:expr) => {
3346        match $scrutinee {
3347            DerivativeMatrixStorage::Dense($backend) => $body,
3348            DerivativeMatrixStorage::Zero($backend) => $body,
3349            DerivativeMatrixStorage::Embedded($backend) => $body,
3350            DerivativeMatrixStorage::Implicit($backend) => $body,
3351            DerivativeMatrixStorage::LatentCoord($backend) => $body,
3352        }
3353    };
3354}
3355
3356#[derive(Clone)]
3357pub(crate) struct ZeroDerivativeMatrix {
3358    rows: usize,
3359    cols: usize,
3360}
3361
3362impl ZeroDerivativeMatrix {
3363    pub(crate) fn new(rows: usize, cols: usize) -> Self {
3364        Self { rows, cols }
3365    }
3366}
3367
3368/// Which derivative level the implicit operator should compute.
3369#[derive(Clone, Copy, Debug)]
3370pub enum ImplicitDerivLevel {
3371    /// ∂X/∂ψ_d
3372    First(usize),
3373    /// ∂²X/∂ψ_d²
3374    SecondDiag(usize),
3375    /// ∂²X/∂ψ_d∂ψ_e
3376    SecondCross(usize, usize),
3377}
3378
3379/// Lazy implicit operator storage: delegates matvecs to the
3380/// `ImplicitDesignPsiDerivative` and materializes dense form only on demand.
3381#[derive(Clone)]
3382pub(crate) struct ImplicitDerivativeOp {
3383    pub(crate) operator: std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3384    pub(crate) level: ImplicitDerivLevel,
3385    pub(crate) global_range: Range<usize>,
3386    pub(crate) total_dim: usize,
3387    /// Cached dense materialization (lazy, populated on first call to ops that need the full matrix).
3388    ///
3389    /// Rayon-safe: `materialize_local` calls `materialize_first` / `_second_diag`
3390    /// / `_second_cross` on the implicit basis-derivative operator, which for
3391    /// streaming bases dispatches `(0..nc).into_par_iter().for_each(...)`. A plain
3392    /// `std::sync::OnceLock` here would deadlock if `materialize_dense` were first
3393    /// called concurrently from inside another rayon par_iter — racing workers
3394    /// would park on the OnceLock's OS condvar, leaving the leader's nested
3395    /// par_iter without workers. `RayonSafeOnce` runs init lock-free.
3396    pub(crate) cached_dense: std::sync::Arc<gam_runtime::resource::RayonSafeOnce<Array2<f64>>>,
3397}
3398
3399#[derive(Clone)]
3400pub(crate) struct LatentCoordDerivativeOp {
3401    pub(crate) operator: std::sync::Arc<gam_terms::basis::LatentCoordDesignDerivative>,
3402    pub(crate) flat_axis: usize,
3403    pub(crate) global_range: Range<usize>,
3404    pub(crate) total_dim: usize,
3405    pub(crate) cached_dense: std::sync::Arc<gam_runtime::resource::RayonSafeOnce<Array2<f64>>>,
3406}
3407
3408impl LatentCoordDerivativeOp {
3409    pub(crate) fn materialize_local(&self) -> Array2<f64> {
3410        self.operator.materialize_axis(self.flat_axis).expect(
3411            "radial scalar evaluation failed during latent-coordinate derivative materialization",
3412        )
3413    }
3414
3415    pub(crate) fn materialize_dense(&self) -> &Array2<f64> {
3416        self.cached_dense.get_or_compute(|| {
3417            let local = self.materialize_local();
3418            let mut out = Array2::<f64>::zeros((local.nrows(), self.total_dim));
3419            out.slice_mut(s![.., self.global_range.clone()])
3420                .assign(&local);
3421            out
3422        })
3423    }
3424
3425    pub(crate) fn nrows(&self) -> usize {
3426        self.operator.n_data()
3427    }
3428
3429    pub(crate) fn ncols(&self) -> usize {
3430        self.total_dim
3431    }
3432
3433    pub(crate) fn transpose_mul(&self, v: &Array1<f64>) -> Array1<f64> {
3434        let local = self
3435            .operator
3436            .transpose_mul_axis(self.flat_axis, &v.view())
3437            .expect(
3438                "radial scalar evaluation failed during latent-coordinate derivative transpose_mul",
3439            );
3440        let mut out = Array1::<f64>::zeros(self.total_dim);
3441        out.slice_mut(s![self.global_range.clone()]).assign(&local);
3442        out
3443    }
3444
3445    pub(crate) fn forward_mul(&self, u: &Array1<f64>) -> Array1<f64> {
3446        let u_local = u.slice(s![self.global_range.clone()]).to_owned();
3447        self.operator
3448            .forward_mul_axis(self.flat_axis, &u_local.view())
3449            .expect(
3450                "radial scalar evaluation failed during latent-coordinate derivative forward_mul",
3451            )
3452    }
3453}
3454
3455impl ImplicitDerivativeOp {
3456    pub(crate) fn materialize_local(&self) -> Array2<f64> {
3457        match self.level {
3458            ImplicitDerivLevel::First(axis) => self.operator.materialize_first(axis).expect(
3459                "radial scalar evaluation failed during implicit derivative materialization",
3460            ),
3461            ImplicitDerivLevel::SecondDiag(axis) => {
3462                self.operator.materialize_second_diag(axis).expect(
3463                    "radial scalar evaluation failed during implicit derivative materialization",
3464                )
3465            }
3466            ImplicitDerivLevel::SecondCross(d, e) => {
3467                self.operator.materialize_second_cross(d, e).expect(
3468                    "radial scalar evaluation failed during implicit derivative materialization",
3469                )
3470            }
3471        }
3472    }
3473
3474    pub(crate) fn materialize_dense(&self) -> &Array2<f64> {
3475        self.cached_dense.get_or_compute(|| {
3476            let local = self.materialize_local();
3477            let mut out = Array2::<f64>::zeros((local.nrows(), self.total_dim));
3478            out.slice_mut(s![.., self.global_range.clone()])
3479                .assign(&local);
3480            out
3481        })
3482    }
3483
3484    pub(crate) fn nrows(&self) -> usize {
3485        self.operator.n_data()
3486    }
3487
3488    pub(crate) fn ncols(&self) -> usize {
3489        self.total_dim
3490    }
3491
3492    pub(crate) fn transpose_mul(&self, v: &Array1<f64>) -> Array1<f64> {
3493        let local = match self.level {
3494            ImplicitDerivLevel::First(axis) => self
3495                .operator
3496                .transpose_mul(axis, &v.view())
3497                .expect("radial scalar evaluation failed during implicit derivative transpose_mul"),
3498            ImplicitDerivLevel::SecondDiag(axis) => self
3499                .operator
3500                .transpose_mul_second_diag(axis, &v.view())
3501                .expect("radial scalar evaluation failed during implicit derivative transpose_mul"),
3502            ImplicitDerivLevel::SecondCross(d, e) => self
3503                .operator
3504                .transpose_mul_second_cross(d, e, &v.view())
3505                .expect("radial scalar evaluation failed during implicit derivative transpose_mul"),
3506        };
3507        let mut out = Array1::<f64>::zeros(self.total_dim);
3508        out.slice_mut(s![self.global_range.clone()]).assign(&local);
3509        out
3510    }
3511
3512    pub(crate) fn forward_mul(&self, u: &Array1<f64>) -> Array1<f64> {
3513        let u_local = u.slice(s![self.global_range.clone()]).to_owned();
3514        match self.level {
3515            ImplicitDerivLevel::First(axis) => self
3516                .operator
3517                .forward_mul(axis, &u_local.view())
3518                .expect("radial scalar evaluation failed during implicit derivative forward_mul"),
3519            ImplicitDerivLevel::SecondDiag(axis) => self
3520                .operator
3521                .forward_mul_second_diag(axis, &u_local.view())
3522                .expect("radial scalar evaluation failed during implicit derivative forward_mul"),
3523            ImplicitDerivLevel::SecondCross(d, e) => self
3524                .operator
3525                .forward_mul_second_cross(d, e, &u_local.view())
3526                .expect("radial scalar evaluation failed during implicit derivative forward_mul"),
3527        }
3528    }
3529}
3530
3531#[derive(Clone)]
3532pub(crate) struct EmbeddedDerivativeMatrix {
3533    pub(crate) local: Array2<f64>,
3534    pub(crate) global_range: Range<usize>,
3535    pub(crate) total_dim: usize,
3536}
3537
3538impl EmbeddedDerivativeMatrix {
3539    pub(crate) fn new(local: Array2<f64>, global_range: Range<usize>, total_dim: usize) -> Self {
3540        Self {
3541            local,
3542            global_range,
3543            total_dim,
3544        }
3545    }
3546}
3547
3548impl DerivativeStorageBackend for Array2<f64> {
3549    fn resident_byte_count(&self) -> usize {
3550        self.len().saturating_mul(std::mem::size_of::<f64>())
3551    }
3552    fn design_nrows(&self) -> usize {
3553        Array2::nrows(self)
3554    }
3555    fn design_ncols(&self) -> usize {
3556        Array2::ncols(self)
3557    }
3558    fn penalty_dim(&self) -> usize {
3559        Array2::nrows(self)
3560    }
3561    fn uses_implicit_storage(&self) -> bool {
3562        false
3563    }
3564    fn any_nonzero(&self) -> bool {
3565        self.iter().any(|v| *v != 0.0)
3566    }
3567    fn materialize(&self) -> Array2<f64> {
3568        self.clone()
3569    }
3570    fn implicit_first_axis_info(
3571        &self,
3572    ) -> Option<(
3573        std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3574        usize,
3575    )> {
3576        None
3577    }
3578    fn implicit_axis_count_hint(&self) -> Option<usize> {
3579        None
3580    }
3581
3582    fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3583        if Array2::ncols(self) != u.len() {
3584            crate::bail_invalid_estim!(
3585                "dense hyper design derivative forward_mul_original width mismatch: matrix={}x{}, vector={}",
3586                Array2::nrows(self),
3587                Array2::ncols(self),
3588                u.len()
3589            );
3590        }
3591        Ok(self.dot(u))
3592    }
3593
3594    fn design_transpose_mul_original(
3595        &self,
3596        v: &Array1<f64>,
3597    ) -> Result<Array1<f64>, EstimationError> {
3598        if Array2::nrows(self) != v.len() {
3599            crate::bail_invalid_estim!(
3600                "dense hyper design derivative transpose_mul_original height mismatch: matrix={}x{}, vector={}",
3601                Array2::nrows(self),
3602                Array2::ncols(self),
3603                v.len()
3604            );
3605        }
3606        Ok(self.t().dot(v))
3607    }
3608
3609    fn design_transformed(
3610        &self,
3611        qs: &Array2<f64>,
3612        free_basis_opt: Option<&Array2<f64>>,
3613    ) -> Result<Array2<f64>, EstimationError> {
3614        Ok(gam_linalg::matrix::DenseRightProductView::new(self)
3615            .with_factor(qs)
3616            .with_optional_factor(free_basis_opt)
3617            .materialize())
3618    }
3619
3620    fn penalty_transformed(
3621        &self,
3622        qs: &Array2<f64>,
3623        free_basis_opt: Option<&Array2<f64>>,
3624    ) -> Result<Array2<f64>, EstimationError> {
3625        let mut transformed = qs.t().dot(self).dot(qs);
3626        if let Some(z) = free_basis_opt {
3627            transformed = z.t().dot(&transformed).dot(z);
3628        }
3629        Ok(transformed)
3630    }
3631
3632    fn penalty_scaled_add_to(
3633        &self,
3634        target: &mut Array2<f64>,
3635        amp: f64,
3636    ) -> Result<(), EstimationError> {
3637        if target.raw_dim() != self.raw_dim() {
3638            crate::bail_invalid_estim!(
3639                "dense hyper penalty derivative shape mismatch: target={}x{}, matrix={}x{}",
3640                target.nrows(),
3641                target.ncols(),
3642                Array2::nrows(self),
3643                Array2::ncols(self)
3644            );
3645        }
3646        target.scaled_add(amp, self);
3647        Ok(())
3648    }
3649}
3650
3651impl DerivativeStorageBackend for ZeroDerivativeMatrix {
3652    fn resident_byte_count(&self) -> usize {
3653        0
3654    }
3655    fn design_nrows(&self) -> usize {
3656        self.rows
3657    }
3658    fn design_ncols(&self) -> usize {
3659        self.cols
3660    }
3661    fn penalty_dim(&self) -> usize {
3662        self.cols
3663    }
3664    fn uses_implicit_storage(&self) -> bool {
3665        false
3666    }
3667    fn any_nonzero(&self) -> bool {
3668        false
3669    }
3670    fn materialize(&self) -> Array2<f64> {
3671        Array2::<f64>::zeros((self.rows, self.cols))
3672    }
3673    fn implicit_first_axis_info(
3674        &self,
3675    ) -> Option<(
3676        std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3677        usize,
3678    )> {
3679        None
3680    }
3681    fn implicit_axis_count_hint(&self) -> Option<usize> {
3682        None
3683    }
3684
3685    fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3686        if self.cols != u.len() {
3687            crate::bail_invalid_estim!(
3688                "zero hyper design derivative forward_mul_original width mismatch: matrix={}x{}, vector={}",
3689                self.rows,
3690                self.cols,
3691                u.len()
3692            );
3693        }
3694        Ok(Array1::<f64>::zeros(self.rows))
3695    }
3696
3697    fn design_transpose_mul_original(
3698        &self,
3699        v: &Array1<f64>,
3700    ) -> Result<Array1<f64>, EstimationError> {
3701        if self.rows != v.len() {
3702            crate::bail_invalid_estim!(
3703                "zero hyper design derivative transpose_mul_original height mismatch: matrix={}x{}, vector={}",
3704                self.rows,
3705                self.cols,
3706                v.len()
3707            );
3708        }
3709        Ok(Array1::<f64>::zeros(self.cols))
3710    }
3711
3712    fn design_transformed(
3713        &self,
3714        qs: &Array2<f64>,
3715        free_basis_opt: Option<&Array2<f64>>,
3716    ) -> Result<Array2<f64>, EstimationError> {
3717        if self.cols != qs.nrows() {
3718            crate::bail_invalid_estim!(
3719                "zero design derivative width mismatch: total_cols={}, qs rows={}",
3720                self.cols,
3721                qs.nrows()
3722            );
3723        }
3724        let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3725        Ok(Array2::<f64>::zeros((self.rows, cols)))
3726    }
3727
3728    fn design_transformed_forward_mul(
3729        &self,
3730        qs: &Array2<f64>,
3731        free_basis_opt: Option<&Array2<f64>>,
3732        u: &Array1<f64>,
3733    ) -> Result<Array1<f64>, EstimationError> {
3734        if self.cols != qs.nrows() {
3735            crate::bail_invalid_estim!(
3736                "zero design derivative width mismatch: total_cols={}, qs rows={}",
3737                self.cols,
3738                qs.nrows()
3739            );
3740        }
3741        let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3742        if u.len() != cols {
3743            crate::bail_invalid_estim!(
3744                "zero design derivative transformed forward width mismatch: expected {}, vector={}",
3745                cols,
3746                u.len()
3747            );
3748        }
3749        Ok(Array1::<f64>::zeros(self.rows))
3750    }
3751
3752    fn design_transformed_transpose_mul(
3753        &self,
3754        qs: &Array2<f64>,
3755        free_basis_opt: Option<&Array2<f64>>,
3756        v: &Array1<f64>,
3757    ) -> Result<Array1<f64>, EstimationError> {
3758        if self.rows != v.len() {
3759            crate::bail_invalid_estim!(
3760                "zero design derivative transpose height mismatch: matrix rows={}, vector={}",
3761                self.rows,
3762                v.len()
3763            );
3764        }
3765        if self.cols != qs.nrows() {
3766            crate::bail_invalid_estim!(
3767                "zero design derivative width mismatch: total_cols={}, qs rows={}",
3768                self.cols,
3769                qs.nrows()
3770            );
3771        }
3772        let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3773        Ok(Array1::<f64>::zeros(cols))
3774    }
3775
3776    fn penalty_transformed(
3777        &self,
3778        qs: &Array2<f64>,
3779        free_basis_opt: Option<&Array2<f64>>,
3780    ) -> Result<Array2<f64>, EstimationError> {
3781        if self.cols != qs.nrows() {
3782            crate::bail_invalid_estim!(
3783                "zero penalty derivative width mismatch: total_dim={}, qs rows={}",
3784                self.cols,
3785                qs.nrows()
3786            );
3787        }
3788        let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3789        Ok(Array2::<f64>::zeros((cols, cols)))
3790    }
3791
3792    fn penalty_scaled_add_to(
3793        &self,
3794        target: &mut Array2<f64>,
3795        amp: f64,
3796    ) -> Result<(), EstimationError> {
3797        // Zero penalty derivative: `amp · 0 = 0`, so `amp` scales nothing and
3798        // `target` is left unchanged. Validate it is finite so a bad scale
3799        // surfaces here rather than silently no-op'ing on a NaN/inf amplitude.
3800        if !amp.is_finite() {
3801            crate::bail_invalid_estim!(
3802                "zero hyper penalty derivative received non-finite amp={amp}"
3803            );
3804        }
3805        if target.nrows() != self.cols || target.ncols() != self.cols {
3806            crate::bail_invalid_estim!(
3807                "zero hyper penalty derivative shape mismatch: target={}x{}, expected {}x{}",
3808                target.nrows(),
3809                target.ncols(),
3810                self.cols,
3811                self.cols
3812            );
3813        }
3814        Ok(())
3815    }
3816}
3817
3818impl DerivativeStorageBackend for EmbeddedDerivativeMatrix {
3819    fn resident_byte_count(&self) -> usize {
3820        self.local.len().saturating_mul(std::mem::size_of::<f64>())
3821    }
3822    fn design_nrows(&self) -> usize {
3823        self.local.nrows()
3824    }
3825    fn design_ncols(&self) -> usize {
3826        self.total_dim
3827    }
3828    fn penalty_dim(&self) -> usize {
3829        self.total_dim
3830    }
3831    fn uses_implicit_storage(&self) -> bool {
3832        false
3833    }
3834    fn any_nonzero(&self) -> bool {
3835        self.local.iter().any(|v| *v != 0.0)
3836    }
3837    fn materialize(&self) -> Array2<f64> {
3838        let mut dense = Array2::<f64>::zeros((self.local.nrows(), self.total_dim));
3839        dense
3840            .slice_mut(s![.., self.global_range.clone()])
3841            .assign(&self.local);
3842        dense
3843    }
3844    fn implicit_first_axis_info(
3845        &self,
3846    ) -> Option<(
3847        std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3848        usize,
3849    )> {
3850        None
3851    }
3852    fn implicit_axis_count_hint(&self) -> Option<usize> {
3853        None
3854    }
3855
3856    fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3857        if self.total_dim != u.len() {
3858            crate::bail_invalid_estim!(
3859                "embedded hyper design derivative forward_mul_original width mismatch: total_dim={}, vector={}",
3860                self.total_dim,
3861                u.len()
3862            );
3863        }
3864        let u_local = u.slice(s![self.global_range.clone()]).to_owned();
3865        Ok(self.local.dot(&u_local))
3866    }
3867
3868    fn design_transpose_mul_original(
3869        &self,
3870        v: &Array1<f64>,
3871    ) -> Result<Array1<f64>, EstimationError> {
3872        if self.local.nrows() != v.len() {
3873            crate::bail_invalid_estim!(
3874                "embedded hyper design derivative transpose_mul_original height mismatch: local_rows={}, vector={}",
3875                self.local.nrows(),
3876                v.len()
3877            );
3878        }
3879        let mut out = Array1::<f64>::zeros(self.total_dim);
3880        let pulled = self.local.t().dot(v);
3881        out.slice_mut(s![self.global_range.clone()]).assign(&pulled);
3882        Ok(out)
3883    }
3884
3885    fn design_transformed(
3886        &self,
3887        qs: &Array2<f64>,
3888        free_basis_opt: Option<&Array2<f64>>,
3889    ) -> Result<Array2<f64>, EstimationError> {
3890        if self.total_dim != qs.nrows() {
3891            crate::bail_invalid_estim!(
3892                "embedded design derivative width mismatch: total_cols={}, qs rows={}",
3893                self.total_dim,
3894                qs.nrows()
3895            );
3896        }
3897        let qs_local = qs.slice(s![self.global_range.clone(), ..]);
3898        let mut transformed = self.local.dot(&qs_local);
3899        if let Some(z) = free_basis_opt {
3900            transformed = transformed.dot(z);
3901        }
3902        Ok(transformed)
3903    }
3904
3905    fn penalty_transformed(
3906        &self,
3907        qs: &Array2<f64>,
3908        free_basis_opt: Option<&Array2<f64>>,
3909    ) -> Result<Array2<f64>, EstimationError> {
3910        if self.total_dim != qs.nrows() {
3911            crate::bail_invalid_estim!(
3912                "embedded penalty derivative width mismatch: total_dim={}, qs rows={}",
3913                self.total_dim,
3914                qs.nrows()
3915            );
3916        }
3917        let qs_local = qs.slice(s![self.global_range.clone(), ..]);
3918        let mut transformed = qs_local.t().dot(&self.local).dot(&qs_local);
3919        if let Some(z) = free_basis_opt {
3920            transformed = z.t().dot(&transformed).dot(z);
3921        }
3922        Ok(transformed)
3923    }
3924
3925    fn penalty_scaled_add_to(
3926        &self,
3927        target: &mut Array2<f64>,
3928        amp: f64,
3929    ) -> Result<(), EstimationError> {
3930        if target.nrows() != self.total_dim || target.ncols() != self.total_dim {
3931            crate::bail_invalid_estim!(
3932                "embedded hyper penalty derivative shape mismatch: target={}x{}, expected {}x{}",
3933                target.nrows(),
3934                target.ncols(),
3935                self.total_dim,
3936                self.total_dim
3937            );
3938        }
3939        target
3940            .slice_mut(s![self.global_range.clone(), self.global_range.clone()])
3941            .scaled_add(amp, &self.local);
3942        Ok(())
3943    }
3944}
3945
3946impl DerivativeStorageBackend for ImplicitDerivativeOp {
3947    fn resident_byte_count(&self) -> usize {
3948        0
3949    }
3950    fn design_nrows(&self) -> usize {
3951        self.nrows()
3952    }
3953    fn design_ncols(&self) -> usize {
3954        self.ncols()
3955    }
3956    fn penalty_dim(&self) -> usize {
3957        self.nrows()
3958    }
3959    fn uses_implicit_storage(&self) -> bool {
3960        true
3961    }
3962    fn any_nonzero(&self) -> bool {
3963        true
3964    }
3965    fn materialize(&self) -> Array2<f64> {
3966        self.materialize_dense().clone()
3967    }
3968    fn implicit_first_axis_info(
3969        &self,
3970    ) -> Option<(
3971        std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3972        usize,
3973    )> {
3974        match self.level {
3975            ImplicitDerivLevel::First(axis) => Some((self.operator.clone(), axis)),
3976            _ => None,
3977        }
3978    }
3979    fn implicit_axis_count_hint(&self) -> Option<usize> {
3980        Some(self.operator.n_axes())
3981    }
3982
3983    fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3984        if self.ncols() != u.len() {
3985            crate::bail_invalid_estim!(
3986                "implicit hyper design derivative forward_mul_original width mismatch: operator_cols={}, vector={}",
3987                self.ncols(),
3988                u.len()
3989            );
3990        }
3991        Ok(self.forward_mul(u))
3992    }
3993
3994    fn design_transpose_mul_original(
3995        &self,
3996        v: &Array1<f64>,
3997    ) -> Result<Array1<f64>, EstimationError> {
3998        if self.nrows() != v.len() {
3999            crate::bail_invalid_estim!(
4000                "implicit hyper design derivative transpose_mul_original height mismatch: operator_rows={}, vector={}",
4001                self.nrows(),
4002                v.len()
4003            );
4004        }
4005        Ok(self.transpose_mul(v))
4006    }
4007
4008    fn design_transformed(
4009        &self,
4010        qs: &Array2<f64>,
4011        free_basis_opt: Option<&Array2<f64>>,
4012    ) -> Result<Array2<f64>, EstimationError> {
4013        let dense = self.materialize_dense();
4014        Ok(gam_linalg::matrix::DenseRightProductView::new(dense)
4015            .with_factor(qs)
4016            .with_optional_factor(free_basis_opt)
4017            .materialize())
4018    }
4019
4020    fn design_transformed_forward_mul(
4021        &self,
4022        qs: &Array2<f64>,
4023        free_basis_opt: Option<&Array2<f64>>,
4024        u: &Array1<f64>,
4025    ) -> Result<Array1<f64>, EstimationError> {
4026        let mut right = if let Some(z) = free_basis_opt {
4027            z.dot(u)
4028        } else {
4029            u.clone()
4030        };
4031        right = qs.dot(&right);
4032        Ok(self.forward_mul(&right))
4033    }
4034
4035    fn design_transformed_transpose_mul(
4036        &self,
4037        qs: &Array2<f64>,
4038        free_basis_opt: Option<&Array2<f64>>,
4039        v: &Array1<f64>,
4040    ) -> Result<Array1<f64>, EstimationError> {
4041        let mut pulled = qs.t().dot(&self.transpose_mul(v));
4042        if let Some(z) = free_basis_opt {
4043            pulled = z.t().dot(&pulled);
4044        }
4045        Ok(pulled)
4046    }
4047
4048    fn penalty_transformed(
4049        &self,
4050        qs: &Array2<f64>,
4051        free_basis_opt: Option<&Array2<f64>>,
4052    ) -> Result<Array2<f64>, EstimationError> {
4053        let dense = self.materialize_dense();
4054        let mut transformed = qs.t().dot(dense).dot(qs);
4055        if let Some(z) = free_basis_opt {
4056            transformed = z.t().dot(&transformed).dot(z);
4057        }
4058        Ok(transformed)
4059    }
4060
4061    fn penalty_scaled_add_to(
4062        &self,
4063        target: &mut Array2<f64>,
4064        amp: f64,
4065    ) -> Result<(), EstimationError> {
4066        let dense = self.materialize_dense();
4067        if target.raw_dim() != dense.raw_dim() {
4068            crate::bail_invalid_estim!(
4069                "implicit hyper penalty derivative shape mismatch: target={}x{}, matrix={}x{}",
4070                target.nrows(),
4071                target.ncols(),
4072                dense.nrows(),
4073                dense.ncols()
4074            );
4075        }
4076        target.scaled_add(amp, dense);
4077        Ok(())
4078    }
4079}
4080
4081impl DerivativeStorageBackend for LatentCoordDerivativeOp {
4082    fn resident_byte_count(&self) -> usize {
4083        0
4084    }
4085    fn design_nrows(&self) -> usize {
4086        self.nrows()
4087    }
4088    fn design_ncols(&self) -> usize {
4089        self.ncols()
4090    }
4091    fn penalty_dim(&self) -> usize {
4092        self.nrows()
4093    }
4094    fn uses_implicit_storage(&self) -> bool {
4095        true
4096    }
4097    fn any_nonzero(&self) -> bool {
4098        true
4099    }
4100    fn materialize(&self) -> Array2<f64> {
4101        self.materialize_dense().clone()
4102    }
4103    fn implicit_first_axis_info(
4104        &self,
4105    ) -> Option<(
4106        std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4107        usize,
4108    )> {
4109        None
4110    }
4111    fn implicit_axis_count_hint(&self) -> Option<usize> {
4112        Some(self.operator.n_axes())
4113    }
4114
4115    fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
4116        if self.ncols() != u.len() {
4117            crate::bail_invalid_estim!(
4118                "latent-coordinate hyper design derivative forward_mul_original width mismatch: operator_cols={}, vector={}",
4119                self.ncols(),
4120                u.len()
4121            );
4122        }
4123        Ok(self.forward_mul(u))
4124    }
4125
4126    fn design_transpose_mul_original(
4127        &self,
4128        v: &Array1<f64>,
4129    ) -> Result<Array1<f64>, EstimationError> {
4130        if self.nrows() != v.len() {
4131            crate::bail_invalid_estim!(
4132                "latent-coordinate hyper design derivative transpose_mul_original height mismatch: operator_rows={}, vector={}",
4133                self.nrows(),
4134                v.len()
4135            );
4136        }
4137        Ok(self.transpose_mul(v))
4138    }
4139
4140    fn design_transformed(
4141        &self,
4142        qs: &Array2<f64>,
4143        free_basis_opt: Option<&Array2<f64>>,
4144    ) -> Result<Array2<f64>, EstimationError> {
4145        let dense = self.materialize_dense();
4146        Ok(gam_linalg::matrix::DenseRightProductView::new(dense)
4147            .with_factor(qs)
4148            .with_optional_factor(free_basis_opt)
4149            .materialize())
4150    }
4151
4152    fn design_transformed_forward_mul(
4153        &self,
4154        qs: &Array2<f64>,
4155        free_basis_opt: Option<&Array2<f64>>,
4156        u: &Array1<f64>,
4157    ) -> Result<Array1<f64>, EstimationError> {
4158        let mut right = if let Some(z) = free_basis_opt {
4159            z.dot(u)
4160        } else {
4161            u.clone()
4162        };
4163        right = qs.dot(&right);
4164        Ok(self.forward_mul(&right))
4165    }
4166
4167    fn design_transformed_transpose_mul(
4168        &self,
4169        qs: &Array2<f64>,
4170        free_basis_opt: Option<&Array2<f64>>,
4171        v: &Array1<f64>,
4172    ) -> Result<Array1<f64>, EstimationError> {
4173        let mut pulled = qs.t().dot(&self.transpose_mul(v));
4174        if let Some(z) = free_basis_opt {
4175            pulled = z.t().dot(&pulled);
4176        }
4177        Ok(pulled)
4178    }
4179
4180    fn penalty_transformed(
4181        &self,
4182        qs: &Array2<f64>,
4183        free_basis_opt: Option<&Array2<f64>>,
4184    ) -> Result<Array2<f64>, EstimationError> {
4185        let dense = self.materialize_dense();
4186        let mut transformed = qs.t().dot(dense).dot(qs);
4187        if let Some(z) = free_basis_opt {
4188            transformed = z.t().dot(&transformed).dot(z);
4189        }
4190        Ok(transformed)
4191    }
4192
4193    fn penalty_scaled_add_to(
4194        &self,
4195        target: &mut Array2<f64>,
4196        amp: f64,
4197    ) -> Result<(), EstimationError> {
4198        let dense = self.materialize_dense();
4199        if target.raw_dim() != dense.raw_dim() {
4200            crate::bail_invalid_estim!(
4201                "latent-coordinate hyper penalty derivative shape mismatch: target={}x{}, matrix={}x{}",
4202                target.nrows(),
4203                target.ncols(),
4204                dense.nrows(),
4205                dense.ncols()
4206            );
4207        }
4208        target.scaled_add(amp, dense);
4209        Ok(())
4210    }
4211}
4212
4213#[derive(Clone)]
4214pub struct HyperDesignDerivative {
4215    pub(crate) storage: DerivativeMatrixStorage,
4216}
4217
4218impl HyperDesignDerivative {
4219    pub fn zero(nrows: usize, ncols: usize) -> Self {
4220        Self {
4221            storage: DerivativeMatrixStorage::Zero(ZeroDerivativeMatrix::new(nrows, ncols)),
4222        }
4223    }
4224
4225    pub fn from_embedded(
4226        local: Array2<f64>,
4227        global_range: Range<usize>,
4228        total_cols: usize,
4229    ) -> Self {
4230        Self {
4231            storage: DerivativeMatrixStorage::Embedded(EmbeddedDerivativeMatrix::new(
4232                local,
4233                global_range,
4234                total_cols,
4235            )),
4236        }
4237    }
4238
4239    pub fn from_implicit(
4240        operator: std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4241        level: ImplicitDerivLevel,
4242        global_range: Range<usize>,
4243        total_cols: usize,
4244    ) -> Self {
4245        Self {
4246            storage: DerivativeMatrixStorage::Implicit(ImplicitDerivativeOp {
4247                operator,
4248                level,
4249                global_range,
4250                total_dim: total_cols,
4251                cached_dense: std::sync::Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
4252            }),
4253        }
4254    }
4255
4256    pub fn from_latent_coord(
4257        operator: std::sync::Arc<gam_terms::basis::LatentCoordDesignDerivative>,
4258        flat_axis: usize,
4259        global_range: Range<usize>,
4260        total_cols: usize,
4261    ) -> Self {
4262        Self {
4263            storage: DerivativeMatrixStorage::LatentCoord(LatentCoordDerivativeOp {
4264                operator,
4265                flat_axis,
4266                global_range,
4267                total_dim: total_cols,
4268                cached_dense: std::sync::Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
4269            }),
4270        }
4271    }
4272
4273    pub(crate) fn resident_byte_count(&self) -> usize {
4274        storage_dispatch!(&self.storage, b => b.resident_byte_count())
4275    }
4276
4277    pub(crate) fn nrows(&self) -> usize {
4278        storage_dispatch!(&self.storage, b => b.design_nrows())
4279    }
4280
4281    pub(crate) fn ncols(&self) -> usize {
4282        storage_dispatch!(&self.storage, b => b.design_ncols())
4283    }
4284
4285    pub(crate) fn uses_implicit_storage(&self) -> bool {
4286        storage_dispatch!(&self.storage, b => b.uses_implicit_storage())
4287    }
4288
4289    pub(crate) fn materialize(&self) -> Array2<f64> {
4290        storage_dispatch!(&self.storage, b => b.materialize())
4291    }
4292
4293    pub(crate) fn any_nonzero(&self) -> bool {
4294        storage_dispatch!(&self.storage, b => b.any_nonzero())
4295    }
4296
4297    pub(crate) fn forward_mul_original(
4298        &self,
4299        u: &Array1<f64>,
4300    ) -> Result<Array1<f64>, EstimationError> {
4301        storage_dispatch!(&self.storage, b => b.design_forward_mul_original(u))
4302    }
4303
4304    pub(crate) fn transpose_mul_original(
4305        &self,
4306        v: &Array1<f64>,
4307    ) -> Result<Array1<f64>, EstimationError> {
4308        storage_dispatch!(&self.storage, b => b.design_transpose_mul_original(v))
4309    }
4310
4311    pub(crate) fn transformed(
4312        &self,
4313        qs: &Array2<f64>,
4314        free_basis_opt: Option<&Array2<f64>>,
4315    ) -> Result<Array2<f64>, EstimationError> {
4316        storage_dispatch!(&self.storage, b => b.design_transformed(qs, free_basis_opt))
4317    }
4318
4319    pub(crate) fn transformed_forward_mul(
4320        &self,
4321        qs: &Array2<f64>,
4322        free_basis_opt: Option<&Array2<f64>>,
4323        u: &Array1<f64>,
4324    ) -> Result<Array1<f64>, EstimationError> {
4325        storage_dispatch!(&self.storage, b => b.design_transformed_forward_mul(qs, free_basis_opt, u))
4326    }
4327
4328    pub(crate) fn transformed_transpose_mul(
4329        &self,
4330        qs: &Array2<f64>,
4331        free_basis_opt: Option<&Array2<f64>>,
4332        v: &Array1<f64>,
4333    ) -> Result<Array1<f64>, EstimationError> {
4334        storage_dispatch!(&self.storage, b => b.design_transformed_transpose_mul(qs, free_basis_opt, v))
4335    }
4336
4337    /// If this derivative uses implicit storage at the first-derivative level,
4338    /// return the shared implicit operator and the axis index.
4339    ///
4340    /// Returns `None` for dense/embedded storage or for second-derivative levels.
4341    pub(crate) fn implicit_first_axis_info(
4342        &self,
4343    ) -> Option<(
4344        std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4345        usize,
4346    )> {
4347        storage_dispatch!(&self.storage, b => b.implicit_first_axis_info())
4348    }
4349
4350    pub(crate) fn implicit_axis_count_hint(&self) -> Option<usize> {
4351        storage_dispatch!(&self.storage, b => b.implicit_axis_count_hint())
4352    }
4353}
4354
4355impl From<Array2<f64>> for HyperDesignDerivative {
4356    fn from(value: Array2<f64>) -> Self {
4357        Self {
4358            storage: DerivativeMatrixStorage::Dense(value),
4359        }
4360    }
4361}
4362
4363#[derive(Clone)]
4364pub struct HyperPenaltyDerivative {
4365    pub(crate) storage: DerivativeMatrixStorage,
4366}
4367
4368impl HyperPenaltyDerivative {
4369    pub fn from_embedded(local: Array2<f64>, global_range: Range<usize>, total_dim: usize) -> Self {
4370        Self {
4371            storage: DerivativeMatrixStorage::Embedded(EmbeddedDerivativeMatrix::new(
4372                local,
4373                global_range,
4374                total_dim,
4375            )),
4376        }
4377    }
4378
4379    pub(crate) fn resident_byte_count(&self) -> usize {
4380        storage_dispatch!(&self.storage, b => b.resident_byte_count())
4381    }
4382
4383    pub(crate) fn nrows(&self) -> usize {
4384        storage_dispatch!(&self.storage, b => b.penalty_dim())
4385    }
4386
4387    pub(crate) fn ncols(&self) -> usize {
4388        self.nrows()
4389    }
4390
4391    pub(crate) fn scaled_materialize(&self, amp: f64) -> Array2<f64> {
4392        let mut out = Array2::<f64>::zeros((self.nrows(), self.ncols()));
4393        self.scaled_add_to(&mut out, amp)
4394            .expect("scaled materialize uses matching target shape");
4395        out
4396    }
4397
4398    pub(crate) fn transformed(
4399        &self,
4400        qs: &Array2<f64>,
4401        free_basis_opt: Option<&Array2<f64>>,
4402    ) -> Result<Array2<f64>, EstimationError> {
4403        storage_dispatch!(&self.storage, b => b.penalty_transformed(qs, free_basis_opt))
4404    }
4405
4406    pub(crate) fn scaled_add_to(
4407        &self,
4408        target: &mut Array2<f64>,
4409        amp: f64,
4410    ) -> Result<(), EstimationError> {
4411        storage_dispatch!(&self.storage, b => b.penalty_scaled_add_to(target, amp))
4412    }
4413}
4414
4415impl From<Array2<f64>> for HyperPenaltyDerivative {
4416    fn from(value: Array2<f64>) -> Self {
4417        Self {
4418            storage: DerivativeMatrixStorage::Dense(value),
4419        }
4420    }
4421}
4422
4423#[derive(Clone)]
4424pub struct PenaltyDerivativeComponent {
4425    pub penalty_index: usize,
4426    pub matrix: HyperPenaltyDerivative,
4427}
4428
4429#[derive(Clone)]
4430pub struct DirectionalHyperParam {
4431    pub(crate) x_tau_original: HyperDesignDerivative,
4432    // Canonical penalty representation: every tau direction is decomposed into
4433    // base-penalty derivatives. There is no separate "assembled total" path.
4434    pub(crate) penalty_first_components: Vec<PenaltyDerivativeComponent>,
4435    // Optional pairwise second hyper-derivatives against all tau directions.
4436    // If provided, each vector must have length psi_dim and hold an optional
4437    // X_{tau_i,tau_j} entry in original coordinates.
4438    pub(crate) x_tau_tau_original: Option<Vec<Option<HyperDesignDerivative>>>,
4439    // Pairwise second derivatives are stored in the same canonical base-penalty
4440    // decomposition as the first derivatives.
4441    pub(crate) penaltysecond_components: Option<Vec<Option<Vec<PenaltyDerivativeComponent>>>>,
4442    pub(crate) penaltysecond_component_provider: Option<
4443        std::sync::Arc<
4444            dyn Fn(usize) -> Result<Option<Vec<PenaltyDerivativeComponent>>, EstimationError>
4445                + Send
4446                + Sync
4447                + 'static,
4448        >,
4449    >,
4450    pub(crate) penaltysecond_partner_indices: Option<std::sync::Arc<[usize]>>,
4451    /// Whether this coordinate is penalty-like (B_i = ∂H/∂τ_i is PSD).
4452    /// True for τ (penalty scaling) coordinates; false for ψ (design-moving,
4453    /// anisotropic length-scale) coordinates. Controls EFS eligibility.
4454    pub(crate) is_penalty_like: bool,
4455}
4456
4457impl DirectionalHyperParam {
4458    pub(crate) fn resident_byte_count(&self) -> usize {
4459        let mut bytes = self.x_tau_original.resident_byte_count();
4460        for component in &self.penalty_first_components {
4461            bytes = bytes.saturating_add(component.matrix.resident_byte_count());
4462        }
4463        if let Some(entries) = self.x_tau_tau_original.as_ref() {
4464            for entry in entries.iter().flatten() {
4465                bytes = bytes.saturating_add(entry.resident_byte_count());
4466            }
4467        }
4468        if let Some(rows) = self.penaltysecond_components.as_ref() {
4469            for components in rows.iter().flatten() {
4470                for component in components {
4471                    bytes = bytes.saturating_add(component.matrix.resident_byte_count());
4472                }
4473            }
4474        }
4475        bytes
4476    }
4477
4478    pub(crate) fn canonicalize_penalty_components(
4479        components: Vec<(usize, HyperPenaltyDerivative)>,
4480    ) -> Result<Vec<PenaltyDerivativeComponent>, EstimationError> {
4481        let mut out: Vec<PenaltyDerivativeComponent> = Vec::with_capacity(components.len());
4482        for (penalty_index, matrix) in components {
4483            if out.iter().any(|c| c.penalty_index == penalty_index) {
4484                crate::bail_invalid_estim!(
4485                    "duplicate penalty derivative component for penalty {}",
4486                    penalty_index
4487                );
4488            }
4489            out.push(PenaltyDerivativeComponent {
4490                penalty_index,
4491                matrix,
4492            });
4493        }
4494        Ok(out)
4495    }
4496
4497    pub fn new_compact(
4498        x_tau_original: HyperDesignDerivative,
4499        penalty_first_components: Vec<(usize, HyperPenaltyDerivative)>,
4500        x_tau_tau_original: Option<Vec<Option<HyperDesignDerivative>>>,
4501        penaltysecond_components: Option<Vec<Option<Vec<(usize, HyperPenaltyDerivative)>>>>,
4502    ) -> Result<Self, EstimationError> {
4503        let is_penalty_like = !x_tau_original.any_nonzero();
4504        let penalty_first_components =
4505            Self::canonicalize_penalty_components(penalty_first_components)?;
4506        let penaltysecond_components = match penaltysecond_components {
4507            Some(rows) => {
4508                let mut out = Vec::with_capacity(rows.len());
4509                for row in rows {
4510                    out.push(match row {
4511                        Some(components) => {
4512                            Some(Self::canonicalize_penalty_components(components)?)
4513                        }
4514                        None => None,
4515                    });
4516                }
4517                Some(out)
4518            }
4519            None => None,
4520        };
4521        Ok(Self {
4522            x_tau_original,
4523            penalty_first_components,
4524            x_tau_tau_original,
4525            penaltysecond_components,
4526            penaltysecond_component_provider: None,
4527            penaltysecond_partner_indices: None,
4528            is_penalty_like,
4529        })
4530    }
4531
4532    /// Mark this coordinate as non-penalty-like (design-moving).
4533    /// EFS will skip it; use Newton/BFGS for these coordinates.
4534    pub fn not_penalty_like(mut self) -> Self {
4535        self.is_penalty_like = false;
4536        self
4537    }
4538
4539    pub fn with_penaltysecond_component_provider(
4540        mut self,
4541        provider: std::sync::Arc<
4542            dyn Fn(usize) -> Result<Option<Vec<PenaltyDerivativeComponent>>, EstimationError>
4543                + Send
4544                + Sync
4545                + 'static,
4546        >,
4547    ) -> Self {
4548        self.penaltysecond_component_provider = Some(provider);
4549        self
4550    }
4551
4552    pub fn with_penaltysecond_partner_indices(mut self, partners: Vec<usize>) -> Self {
4553        self.penaltysecond_partner_indices = Some(std::sync::Arc::from(partners));
4554        self
4555    }
4556
4557    pub(crate) fn x_tau_dense(&self) -> Array2<f64> {
4558        self.x_tau_original.materialize()
4559    }
4560
4561    pub(crate) fn transformed_x_tau(
4562        &self,
4563        qs: &Array2<f64>,
4564        free_basis_opt: Option<&Array2<f64>>,
4565    ) -> Result<Array2<f64>, EstimationError> {
4566        self.x_tau_original.transformed(qs, free_basis_opt)
4567    }
4568
4569    pub(crate) fn x_tau_tau_entry_at(&self, j: usize) -> Option<HyperDesignDerivative> {
4570        self.x_tau_tau_original
4571            .as_ref()
4572            .and_then(|rows| rows.get(j))
4573            .and_then(|entry| entry.clone())
4574    }
4575
4576    /// Whether this coordinate's design derivative uses implicit storage at the
4577    /// first-derivative level.
4578    pub(crate) fn has_implicit_operator(&self) -> bool {
4579        self.x_tau_original.uses_implicit_storage()
4580    }
4581
4582    pub(crate) fn has_implicit_multidim_duchon(&self) -> bool {
4583        self.implicit_first_axis_info()
4584            .is_some_and(|(op, _)| op.n_axes() > 1 && op.is_duchon_family())
4585    }
4586
4587    /// Extract the implicit design derivative operator and axis, if available.
4588    pub(crate) fn implicit_first_axis_info(
4589        &self,
4590    ) -> Option<(
4591        std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4592        usize,
4593    )> {
4594        self.x_tau_original.implicit_first_axis_info()
4595    }
4596
4597    pub(crate) fn implicit_axis_count_hint(&self) -> Option<usize> {
4598        self.x_tau_original.implicit_axis_count_hint()
4599    }
4600
4601    pub(crate) fn penalty_first_components(&self) -> &[PenaltyDerivativeComponent] {
4602        &self.penalty_first_components
4603    }
4604
4605    pub(crate) fn penalty_total_at(
4606        &self,
4607        rho: &Array1<f64>,
4608        p: usize,
4609    ) -> Result<Array2<f64>, EstimationError> {
4610        let mut out = Array2::<f64>::zeros((p, p));
4611        for component in &self.penalty_first_components {
4612            if component.matrix.nrows() != p || component.matrix.ncols() != p {
4613                crate::bail_invalid_estim!(
4614                    "S_tau shape mismatch for penalty {}: expected {}x{}, got {}x{}",
4615                    component.penalty_index,
4616                    p,
4617                    p,
4618                    component.matrix.nrows(),
4619                    component.matrix.ncols()
4620                );
4621            }
4622            if component.penalty_index >= rho.len() {
4623                crate::bail_invalid_estim!(
4624                    "penalty_index {} out of bounds for rho dimension {}",
4625                    component.penalty_index,
4626                    rho.len()
4627                );
4628            }
4629            let lambda = gam_problem::checked_exp_log_strength(rho[component.penalty_index])
4630                .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
4631            component.matrix.scaled_add_to(&mut out, lambda)?;
4632        }
4633        Ok(out)
4634    }
4635
4636    pub(crate) fn penaltysecond_components_for(
4637        &self,
4638        j: usize,
4639    ) -> Result<Option<Vec<PenaltyDerivativeComponent>>, EstimationError> {
4640        if let Some(components) = self
4641            .penaltysecond_components
4642            .as_ref()
4643            .and_then(|rows| rows.get(j))
4644            .and_then(|row| row.clone())
4645        {
4646            return Ok(Some(components));
4647        }
4648        if let Some(provider) = self.penaltysecond_component_provider.as_ref() {
4649            return provider(j);
4650        }
4651        Ok(None)
4652    }
4653
4654    pub(crate) fn penaltysecond_componentrows(
4655        &self,
4656    ) -> Option<&[Option<Vec<PenaltyDerivativeComponent>>]> {
4657        self.penaltysecond_components.as_deref()
4658    }
4659
4660    pub(crate) fn penalty_first_component_count(&self) -> usize {
4661        self.penalty_first_components.len()
4662    }
4663
4664    pub(crate) fn has_penaltysecond_pair_at(&self, j: usize) -> bool {
4665        self.penaltysecond_components
4666            .as_ref()
4667            .and_then(|rows| rows.get(j))
4668            .is_some_and(Option::is_some)
4669            || self
4670                .penaltysecond_partner_indices
4671                .as_ref()
4672                .is_some_and(|partners| partners.contains(&j))
4673    }
4674}
4675
4676#[derive(Clone, Debug)]
4677pub(crate) struct SparseRemlDecision {
4678    pub(crate) geometry: RemlGeometry,
4679    pub(crate) reason: &'static str,
4680    pub(crate) p: usize,
4681    pub(crate) nnz_x: usize,
4682    pub(crate) nnz_h_upper_est: Option<usize>,
4683    pub(crate) density_h_upper_est: Option<f64>,
4684}
4685
4686#[derive(Clone)]
4687pub(crate) struct SparseExactEvalData {
4688    pub(crate) factor: Arc<SparseExactFactor>,
4689    pub(crate) takahashi: Option<Arc<gam_linalg::sparse_exact::TakahashiInverse>>,
4690    pub(crate) logdet_h: f64,
4691    pub(crate) logdet_s_pos: f64,
4692    pub(crate) penalty_rank: usize,
4693    pub(crate) det1_values: Arc<Array1<f64>>,
4694}
4695
4696#[derive(Clone)]
4697pub struct FirthDenseOperator {
4698    // Exact Firth/Jeffreys objects on the identifiable subspace.
4699    //
4700    // Let X in R^{n×p} potentially be rank-deficient with rank r.
4701    // With optional fixed observation weights a_i >= 0 we define A = diag(a),
4702    // choose an orthonormal coefficient-space basis Q for the identifiable
4703    // subspace of A^{1/2} X, and set:
4704    //   X_r := A^{1/2} X Q          (A = I when no fixed observation weights),
4705    //   W   := diag(w), with w_i = mu_i (1 - mu_i), 0 < w_i <= 1/4 for finite logit eta,
4706    //   I_r := X_rᵀ W X_r,
4707    //   S_r := X_rᵀ X_r.
4708    //
4709    // Firth term is represented as:
4710    //   Phi(beta) = 0.5 log |I_r(beta)| - 0.5 log |S_r|,
4711    // which is exactly
4712    //   0.5 log |Uᵀ W U|
4713    // for the canonical orthonormalized identifiable design
4714    //   U = X_r S_r^{-1/2}.
4715    // This removes the raw-basis term from explicit reduced designs while
4716    // keeping the same identifiable-subspace hat matrix and beta derivatives,
4717    // because S_r is fixed with respect to beta.
4718    //
4719    // Mapping back to the full p-space uses:
4720    //   I_+^dagger = Q I_r^{-1} Qᵀ.
4721    //
4722    // We store reduced-space factors so all derivatives can be evaluated exactly
4723    // without materializing dense n×n matrices M = X K Xᵀ or P = M⊙M.
4724    pub(crate) x_dense: Array2<f64>,
4725    pub(crate) x_dense_t: Array2<f64>,
4726    // Orthonormal coefficient-space basis for the identifiable subspace,
4727    // built from the retained eigenspace of (A^{1/2} X)ᵀ(A^{1/2} X).
4728    pub(crate) q_basis: Array2<f64>,
4729    // Reduced identifiable design. With fixed observation weights a_i this is
4730    // diag(sqrt(a_i)) X Q; otherwise it is X Q.
4731    pub(crate) x_reduced: Array2<f64>,
4732    // Optional fixed case-weight square roots used when the Jeffreys/Firth
4733    // operator is formed from Xᵀ diag(case_weight ⊙ w(η)) X rather than
4734    // Xᵀ diag(w(η)) X. The exact directional tau derivatives must project and
4735    // row-scale with the same weights so the reduced Fisher, hat diagonals,
4736    // and tau kernels all live on one consistent identifiable subspace.
4737    pub(crate) observation_weight_sqrt: Option<Array1<f64>>,
4738    // I_r^{-1}
4739    pub(crate) k_reduced: Array2<f64>,
4740    // diag(S_r^{-1}) with S_r = X_rᵀ X_r. In the current canonical reduced
4741    // basis this completely characterizes the metric inverse, because Q
4742    // diagonalizes the design Gram. It is used to remove the reduced-coordinate
4743    // basis term from Phi_tau when the design moves.
4744    pub(crate) x_metric_reduced_inv_diag: Array1<f64>,
4745    // 0.5 (log|I_r| - log|S_r|) at the current eta.
4746    pub(crate) half_log_det: f64,
4747    // h = diag(M), M = X_r K_r X_r'
4748    pub(crate) h_diag: Array1<f64>,
4749    // Logistic Fisher-weight eta-derivatives: w', w'', w''', w'''' as n-vectors.
4750    pub(crate) w: Array1<f64>,
4751    pub(crate) w1: Array1<f64>,
4752    pub(crate) w2: Array1<f64>,
4753    pub(crate) w3: Array1<f64>,
4754    pub(crate) w4: Array1<f64>,
4755    // B = diag(w') X used in D Hphi and D^2 Hphi contractions.
4756    pub(crate) b_base: Array2<f64>,
4757    // Cached invariant contraction P*B where P = (X_r K_r X_r') ⊙ (X_r K_r X_r').
4758    // This avoids recomputing the same O(n r^2 p) block in every directional call.
4759    pub(crate) p_b_base: Array2<f64>,
4760}
4761
4762/// β-independent (design-only) factor of the Firth/Jeffreys operator.
4763///
4764/// Everything stored here depends ONLY on the fixed design `X` and the fixed
4765/// prior/observation weights `a_i` — NOT on the current linear predictor `η`
4766/// (i.e. NOT on β). For a single inner PIRLS solve the design and prior weights
4767/// are constant while `η` changes every Newton iteration, so this factor can be
4768/// built once per solve and reused, hoisting the O(n·p²) Gram, the O(p³)
4769/// identifiable-subspace eigendecomposition, and the two n×p design clones out
4770/// of the per-iteration hot path (#1575).
4771///
4772/// The β-dependent remainder (Fisher weights `w(η)`, reduced Fisher
4773/// `I_r = X_rᵀ W X_r`, its inverse `K_r`, the hat diagonal `h`, and the
4774/// half-log-determinant) is rebuilt per iteration from this factor via
4775/// [`FirthDenseOperator::build_from_design_factor`]. The design-only work stays
4776/// hoisted while the full per-state operator supplies both PIRLS diagnostics
4777/// and the exact Jeffreys coefficient curvature.
4778#[derive(Clone)]
4779pub(crate) struct FirthDesignFactor {
4780    // Raw design and its transpose (the operator stores owned copies).
4781    pub(crate) x_dense: Array2<f64>,
4782    pub(crate) x_dense_t: Array2<f64>,
4783    // Orthonormal identifiable-subspace basis Q of (A^{1/2} X)ᵀ(A^{1/2} X).
4784    pub(crate) q_basis: Array2<f64>,
4785    // Reduced identifiable design X_r = A^{1/2} X Q.
4786    pub(crate) x_reduced: Array2<f64>,
4787    // Fixed case-weight square roots (sqrt(a_i)), if any.
4788    pub(crate) observation_weight_sqrt: Option<Array1<f64>>,
4789    // Retained positive spectrum of the design Gram = S_r diagonal.
4790    pub(crate) metric_spectrum: Array1<f64>,
4791    // diag(S_r^{-1}); precomputed reciprocal of `metric_spectrum`.
4792    pub(crate) x_metric_reduced_inv_diag: Array1<f64>,
4793    // rank r = ncols(q_basis); n = nrows(x_dense).
4794    pub(crate) r: usize,
4795    pub(crate) n: usize,
4796}
4797
4798#[derive(Clone)]
4799pub(crate) struct FirthDirection {
4800    pub(crate) deta: Array1<f64>,
4801    pub(crate) g_u_reduced: Array2<f64>,
4802    pub(crate) a_u_reduced: Array2<f64>,
4803    pub(crate) dh: Array1<f64>,
4804    // B_u = diag(w'' ⊙ δη_u) X is represented by the row-scaling vector only.
4805    pub(crate) b_uvec: Array1<f64>,
4806}
4807
4808#[derive(Clone)]
4809pub(crate) struct FirthTauPartialKernel {
4810    pub(super) deta_partial: Array1<f64>,
4811    pub(crate) dotw1: Array1<f64>,
4812    pub(crate) dotw2: Array1<f64>,
4813    pub(crate) dot_h_partial: Array1<f64>,
4814    // Reduced design drift X_{tau,r} = X_tau Q used in exact design-moving
4815    // Hadamard-Gram contractions.
4816    pub(crate) x_tau_reduced: Array2<f64>,
4817    pub(super) dot_i_partial: Array2<f64>,
4818    // Reduced Fisher inverse drift:
4819    //   dot(K_r) = -K_r dot(I_r) K_r
4820    // where dot(I_r) includes explicit X_tau and weight drift at beta-fixed.
4821    pub(crate) dot_k_reduced: Array2<f64>,
4822}
4823
4824#[derive(Clone)]
4825pub(crate) struct FirthTauExactKernel {
4826    pub(crate) gphi_tau: Array1<f64>,
4827    pub(crate) phi_tau_partial: f64,
4828    pub(crate) tau_kernel: Option<FirthTauPartialKernel>,
4829}
4830
4831/// Pair-level (τ_i × τ_j) exact Firth bundle at fixed β.
4832///
4833/// Mirrors `FirthTauExactKernel` but for the 2nd-order cross
4834/// derivatives:
4835///   Phi_{τ_i τ_j}|β  (scalar, `phi_tau_tau_partial`)
4836///   (gphi)_{τ_i τ_j}|β (p-vector, `gphi_tau_tau`)
4837///
4838/// Carries an optional `tau_tau_kernel` so pair callbacks can chain
4839/// into Primitive A (`hphi_tau_tau_partial_apply`) for the operator-
4840/// valued Hessian 2nd drift without recomputing shared reduced Grams.
4841///
4842#[derive(Clone)]
4843pub(crate) struct FirthTauTauExactKernel {
4844    pub(super) phi_tau_tau_partial: f64,
4845    pub(super) gphi_tau_tau: Array1<f64>,
4846    pub(super) tau_tau_kernel: Option<FirthTauTauPartialKernel>,
4847}
4848
4849/// Prepared state for `∂²H_φ/∂τ_i ∂τ_j |_β` (Primitive A).
4850///
4851/// Carries both τ-direction reduced designs, their η̇ vectors, and the
4852/// reduced-coordinate drifts (İ, K̇, ḣ) for i and j so the apply step can
4853/// form M̈_{ij}, K̈_{ij}, ḧ_{ij}, Γ̈_{ij}, and B̈_{ij} matrix-free.  Fields
4854/// are filled in by 13b; kept with a neutral internal shape so downstream
4855/// pair callbacks can hold the kernel across the pair dispatch.
4856///
4857/// Wired into the pair-callback's `b_operator` via
4858/// `FirthAugmentedPairHyperOperator`, and produced by both
4859/// `hphi_tau_tau_partial_prepare_from_partials` and
4860/// `exact_tau_tau_kernel` (the scalar/p-vector companion).
4861#[derive(Clone, Default)]
4862pub(crate) struct FirthTauTauPartialKernel {
4863    pub(super) x_tau_i_reduced: Array2<f64>,
4864    pub(super) x_tau_j_reduced: Array2<f64>,
4865    pub(super) deta_i_partial: Array1<f64>,
4866    pub(super) deta_j_partial: Array1<f64>,
4867    pub(super) dot_h_i_partial: Array1<f64>,
4868    pub(super) dot_h_j_partial: Array1<f64>,
4869    pub(super) dot_k_i_reduced: Array2<f64>,
4870    pub(super) dot_k_j_reduced: Array2<f64>,
4871    pub(super) dot_i_i_partial: Array2<f64>,
4872    pub(super) dot_i_j_partial: Array2<f64>,
4873    pub(super) x_tau_tau_reduced: Option<Array2<f64>>,
4874    pub(super) deta_ij_partial: Option<Array1<f64>>,
4875}
4876
4877/// Prepared state for `D_β((H_φ)_τ|_β)[v]` (Primitive B).
4878///
4879/// Carries the τ-kernel pieces (x_tau_reduced, İ, K̇, ḣ), the
4880/// β-direction quantities (δη_v, A_v, dh_v, b-chain), and the mixed
4881/// β-τ pieces (D_β(K̇_τ)[v], D_β(ḣ_τ)[v], δη_{τ,v}) so the apply
4882/// step collapses to the 9-term β-τ expansion without recomputing
4883/// shared reduced Grams.
4884#[derive(Clone, Default)]
4885pub(crate) struct FirthTauBetaPartialKernel {
4886    pub(super) x_tau_reduced: Array2<f64>,
4887    pub(super) deta_partial: Array1<f64>,
4888    pub(super) dot_h_partial: Array1<f64>,
4889    pub(super) dot_i_partial: Array2<f64>,
4890    pub(super) dot_k_reduced: Array2<f64>,
4891    pub(super) deta_v: Array1<f64>,
4892    pub(super) deta_tau_v: Array1<f64>,
4893    pub(super) a_v_reduced: Array2<f64>,
4894    pub(super) dh_v: Array1<f64>,
4895    pub(super) b_vvec: Array1<f64>,
4896    pub(super) d_beta_dot_k: Array2<f64>,
4897    pub(super) d_beta_dot_h: Array1<f64>,
4898}
4899
4900/// Holds the state for the outer REML optimization and supplies cost and
4901/// gradient evaluations to the `opt` optimizer.
4902///
4903/// The `cache` field uses `RefCell` to enable interior mutability. This is a crucial
4904/// performance optimization. The `cost_andgrad` closure required by the BFGS
4905/// optimizer takes an immutable reference `&self`. However, we want to cache the
4906/// results of the expensive P-IRLS computation to avoid re-calculating the fit
4907/// for the same `rho` vector, which can happen during the line search.
4908/// `RefCell` allows us to mutate the cache through a `&self` reference,
4909/// making this optimization possible while adhering to the optimizer's API.
4910#[derive(Clone)]
4911pub(crate) struct EvalShared {
4912    pub(crate) key: Option<Vec<u64>>,
4913    pub(crate) pirls_result: Arc<PirlsResult>,
4914    pub(crate) ridge_passport: RidgePassport,
4915    pub(crate) geometry: RemlGeometry,
4916    /// The exact H_total matrix used for LAML cost computation.
4917    /// For Firth: effective Hessian minus hphi (plus any barrier curvature).
4918    /// For non-Firth: the effective Hessian itself (plus any barrier curvature).
4919    pub(crate) h_total: Arc<Array2<f64>>,
4920    pub(crate) sparse_exact: Option<Arc<SparseExactEvalData>>,
4921    pub(crate) firth_dense_operator: Option<Arc<FirthDenseOperator>>,
4922    /// Cached FirthDenseOperator built from the original (non-reparameterized)
4923    /// design matrix, for use by the sparse evaluation path.
4924    pub(crate) firth_dense_operator_original: Option<Arc<FirthDenseOperator>>,
4925    /// The ONE original-frame penalty pseudo-logdet factorization for this
4926    /// evaluation point (#931 atom discipline). `log|Σ λ_k S_k|₊`'s VALUE,
4927    /// ρ-derivatives, τ/ψ components, and ρ×τ cross blocks are all
4928    /// contractions of this single eigendecomposition; the ρ-side criterion
4929    /// assembly (`dense_penalty_logdet_derivs`, the sparse det2 path) and the
4930    /// original-basis hyper-coordinate builders share it through
4931    /// [`EvalShared::penalty_pseudologdet_original`]. Building a second
4932    /// factorization of the same Sλ for the same evaluation point is the
4933    /// objective↔gradient desync surface (#748/#752/#901) this cell removes:
4934    /// the ridge and positive-eigenspace threshold are decided exactly once.
4935    /// (The transformed-frame pair-callback path builds its own object — it
4936    /// factorizes the canonical-TRANSFORMED, possibly constraint-projected
4937    /// penalties, a genuinely different matrix, not a duplicate of this one.)
4938    pub(crate) penalty_pseudologdet: std::sync::OnceLock<Arc<penalty_logdet::PenaltyPseudologdet>>,
4939    /// Per-evaluation-point cache of the canonical penalty score vectors
4940    /// `S_k β̂` evaluated at this bundle's inner mode `β̂ =
4941    /// pirls_result.beta_transformed` (unscaled by λ_k). These depend ONLY
4942    /// on the inner solution carried by this bundle and the `RemlState`'s
4943    /// fixed `canonical_penalties` — never on which ρ-coordinate or eval
4944    /// mode the assembly is running — so they are computed exactly once per
4945    /// inner solution and shared by every assemble call that reuses the
4946    /// bundle (cost + gradient evaluations at the same ρ, EFS, synthetic-ext
4947    /// value probes). Exact hoist, not an approximation: every consumer sees
4948    /// literally the same vectors it previously recomputed. Initialized via
4949    /// plain ndarray matvecs (no rayon inside the `OnceLock` closure — the
4950    /// `get_or_init`+`into_par_iter` deadlock trap does not apply).
4951    pub(crate) penalty_scores_at_mode: std::sync::OnceLock<Arc<Vec<Array1<f64>>>>,
4952    /// Per-evaluation-point cache of the #784 block-local Laplace-to-sampling
4953    /// correction `TkCorrectionTerms { value, gradient }`. The correction is a
4954    /// deterministic function of ONLY this bundle's converged inner state
4955    /// (`pirls_result`, `h_total`), the `RemlState`'s fixed
4956    /// `canonical_penalties`, and the bundle's ρ — never of the eval `mode`:
4957    /// the diagnostic eigendecomposition, the fixed-seed importance sampler,
4958    /// and the (b)–(d) gradient channels all read mode-invariant fields, and
4959    /// the term carries no Hessian, so the value+gradient are identical for the
4960    /// value-only, value+gradient, and value+gradient+Hessian assemble calls
4961    /// that share this bundle at a single ρ. The expensive path (eigendecomp +
4962    /// O(draws·n·m) sampler) previously reran on every one of those 2–3 calls
4963    /// per outer iteration; hoisting it onto the bundle computes it exactly
4964    /// once per inner solution (exact hoist, identical values — #784, #1082).
4965    /// Keyed only on the external-coordinate count `n_ext`: with no ψ
4966    /// coordinates (`n_ext == 0`) the correction engages; with ψ present the
4967    /// seam declines (returns the cheap zero), and n_ext is fixed for a fit, so
4968    /// a single cell suffices.
4969    pub(crate) block_local_correction:
4970        std::sync::OnceLock<(usize, Arc<outer_eval::TkCorrectionTerms>)>,
4971}
4972
4973impl EvalShared {
4974    pub(crate) fn matches(&self, key: &Option<Vec<u64>>) -> bool {
4975        match (&self.key, key) {
4976            (None, None) => true,
4977            (Some(a), Some(b)) => a == b,
4978            _ => false,
4979        }
4980    }
4981
4982    /// Lazily build — once per evaluation point — the original-frame
4983    /// [`PenaltyPseudologdet`](penalty_logdet::PenaltyPseudologdet) of
4984    /// `Σ λ_k S_k` and hand every caller the SAME factorization.
4985    ///
4986    /// This is the #931 port of the penalty-logdet term: value, ρ-first /
4987    /// ρ-second derivatives, τ-gradient components, τ×τ and ρ×τ Hessian
4988    /// blocks are all projections of one eigendecomposition, so no pair of
4989    /// consumers can disagree about the ridge or the positive-eigenspace
4990    /// threshold. The ridge is read from this bundle's `ridge_passport` —
4991    /// the single place that convention is decided.
4992    ///
4993    /// `lambdas` must be the λ = exp(ρ) vector of this bundle's evaluation
4994    /// point and `p` the original-basis coefficient dimension; on a cache
4995    /// hit both are checked against the stored object where representable.
4996    pub(crate) fn penalty_pseudologdet_original(
4997        &self,
4998        canonical_penalties: &[gam_terms::construction::CanonicalPenalty],
4999        lambdas: &[f64],
5000        p: usize,
5001    ) -> Result<Arc<penalty_logdet::PenaltyPseudologdet>, EstimationError> {
5002        if let Some(pld) = self.penalty_pseudologdet.get() {
5003            if pld.dim() != p {
5004                return Err(EstimationError::LayoutError(format!(
5005                    "shared penalty pseudo-logdet frame mismatch: cached p={}, requested p={}",
5006                    pld.dim(),
5007                    p
5008                )));
5009            }
5010            return Ok(Arc::clone(pld));
5011        }
5012        let pld = Arc::new(
5013            penalty_logdet::PenaltyPseudologdet::from_penalties(
5014                canonical_penalties,
5015                lambdas,
5016                self.ridge_passport.penalty_logdet_ridge(),
5017                p,
5018            )
5019            .map_err(EstimationError::InvalidInput)?,
5020        );
5021        match self.penalty_pseudologdet.set(Arc::clone(&pld)) {
5022            Ok(()) => Ok(pld),
5023            // A concurrent caller initialized the cell first; both objects
5024            // were built from identical inputs — return the canonical winner
5025            // so every consumer holds literally the same factorization.
5026            Err(_) => Ok(Arc::clone(
5027                self.penalty_pseudologdet
5028                    .get()
5029                    .expect("OnceLock set raced, so it is initialized"),
5030            )),
5031        }
5032    }
5033}
5034
5035impl PenalizedGeometry for EvalShared {
5036    fn backend_kind(&self) -> GeometryBackendKind {
5037        match self.geometry {
5038            RemlGeometry::DenseSpectral => GeometryBackendKind::DenseSpectral,
5039            RemlGeometry::SparseExactSpd => GeometryBackendKind::SparseExactSpd,
5040        }
5041    }
5042}
5043
5044/// LRU cache keyed by sanitized ρ vectors that holds compacted PIRLS results
5045/// for warm-starting outer line searches and revisited evaluations.
5046///
5047/// Eviction is byte-budgeted rather than entry-count-budgeted: each entry
5048/// records its own estimated footprint (the surviving n-length vectors plus
5049/// the two p×p Hessians plus per-entry overhead) and the cache evicts in
5050/// LRU order until the running total fits under the budget. An entry that
5051/// individually exceeds the budget is rejected silently rather than poisoning
5052/// the cache.
5053pub(crate) struct PirlsLruCache {
5054    // Stored tuple: (compacted result, last-touched clock, estimated bytes).
5055    pub(crate) map: HashMap<Vec<u64>, (Arc<PirlsResult>, u64, usize)>,
5056    pub(crate) byte_budget: usize,
5057    pub(crate) current_bytes: usize,
5058    pub(crate) clock: u64,
5059}
5060
5061impl PirlsLruCache {
5062    pub(crate) fn new(byte_budget: usize) -> Self {
5063        Self {
5064            map: HashMap::new(),
5065            byte_budget: byte_budget.max(1),
5066            current_bytes: 0,
5067            clock: 0,
5068        }
5069    }
5070
5071    pub(crate) fn get(&mut self, key: &Vec<u64>) -> Option<Arc<PirlsResult>> {
5072        if let Some(entry) = self.map.get_mut(key) {
5073            self.clock += 1;
5074            entry.1 = self.clock;
5075            Some(entry.0.clone())
5076        } else {
5077            None
5078        }
5079    }
5080
5081    pub(crate) fn insert(&mut self, key: Vec<u64>, value: Arc<PirlsResult>) {
5082        self.clock += 1;
5083        let bytes = pirls_result_cache_bytes(&value);
5084        // Refuse entries that on their own already exceed the entire budget;
5085        // caching one would force eviction of every other entry without
5086        // leaving room for the new one anyway.
5087        if bytes > self.byte_budget {
5088            if let Some((_, _, prev_bytes)) = self.map.remove(&key) {
5089                self.current_bytes = self.current_bytes.saturating_sub(prev_bytes);
5090            }
5091            return;
5092        }
5093        if let Some((_, _, prev_bytes)) = self.map.remove(&key) {
5094            self.current_bytes = self.current_bytes.saturating_sub(prev_bytes);
5095        }
5096        while self.current_bytes + bytes > self.byte_budget {
5097            let evict_key = self
5098                .map
5099                .iter()
5100                .min_by_key(|(_, (_, ts, _))| *ts)
5101                .map(|(k, _)| k.clone());
5102            match evict_key {
5103                Some(k) => {
5104                    if let Some((_, _, evict_bytes)) = self.map.remove(&k) {
5105                        self.current_bytes = self.current_bytes.saturating_sub(evict_bytes);
5106                    }
5107                }
5108                None => break,
5109            }
5110        }
5111        self.current_bytes += bytes;
5112        self.map.insert(key, (value, self.clock, bytes));
5113    }
5114
5115    pub(crate) fn clear(&mut self) {
5116        self.map.clear();
5117        self.current_bytes = 0;
5118    }
5119}
5120
5121#[derive(Clone, Copy, PartialEq, Eq)]
5122pub(crate) struct PenaltySubspaceCacheKey {
5123    pub(crate) penalty_matrix_fingerprint: u64,
5124    pub(crate) ridge_passport_signature: u64,
5125}
5126
5127pub(crate) struct PenaltySubspaceCache {
5128    pub(crate) entry: Option<(PenaltySubspaceCacheKey, Arc<outer_eval::PenaltySubspace>)>,
5129}
5130
5131impl PenaltySubspaceCache {
5132    pub(crate) fn new() -> Self {
5133        Self { entry: None }
5134    }
5135
5136    pub(crate) fn get(
5137        &self,
5138        key: &PenaltySubspaceCacheKey,
5139    ) -> Option<Arc<outer_eval::PenaltySubspace>> {
5140        self.entry
5141            .as_ref()
5142            .filter(|(cached_key, _)| cached_key == key)
5143            .map(|(_, value)| value.clone())
5144    }
5145
5146    pub(crate) fn insert(
5147        &mut self,
5148        key: PenaltySubspaceCacheKey,
5149        value: Arc<outer_eval::PenaltySubspace>,
5150    ) {
5151        self.entry = Some((key, value));
5152    }
5153
5154    pub(crate) fn clear(&mut self) {
5155        self.entry = None;
5156    }
5157}
5158
5159impl PenaltySubspaceCacheKey {
5160    /// Build a cache key from the transformed-E matrix and ridge passport.
5161    /// `E` is hashed by exact f64 bits (column-major), so the key is bit-exact
5162    /// and avoids float-Hash issues; the ridge passport is hashed via its
5163    /// `Hash` impl. Two calls at the same `(E, ridge)` yield equal keys.
5164    pub(crate) fn from_inputs(
5165        e_transformed: &ndarray::Array2<f64>,
5166        ridge_passport: &gam_problem::RidgePassport,
5167    ) -> Self {
5168        use std::collections::hash_map::DefaultHasher;
5169        use std::hash::{Hash, Hasher};
5170        let mut hasher = DefaultHasher::new();
5171        e_transformed.nrows().hash(&mut hasher);
5172        e_transformed.ncols().hash(&mut hasher);
5173        for value in e_transformed.iter() {
5174            value.to_bits().hash(&mut hasher);
5175        }
5176        let penalty_matrix_fingerprint = hasher.finish();
5177        let mut ridge_hasher = DefaultHasher::new();
5178        ridge_passport.delta().to_bits().hash(&mut ridge_hasher);
5179        ridge_passport.matrix_form().hash(&mut ridge_hasher);
5180        ridge_passport.policy().hash(&mut ridge_hasher);
5181        let ridge_passport_signature = ridge_hasher.finish();
5182        Self {
5183            penalty_matrix_fingerprint,
5184            ridge_passport_signature,
5185        }
5186    }
5187}
5188
5189/// Estimate the in-cache footprint of a (compacted) PIRLS result.
5190///
5191/// Mirrors what `compact_for_reml_cache` keeps:
5192/// * six surviving n-length f64 arrays (final_eta, solveweights,
5193///   solveworking_response, solvemu, solve_c_array, solve_d_array);
5194/// * the p-length coefficient vector;
5195/// * the two p×p Hessians (dense or CSC sparse);
5196/// * the `ReparamResult` payload — the dominant scaling term beyond n, since
5197///   it carries `s_transformed`, `qs`, and `e_transformed` as p×p / rank×p
5198///   matrices.
5199/// A small constant overhead absorbs scalar fields, enum discriminants, and
5200/// the HashMap entry. This errs on the conservative side: overestimation
5201/// causes earlier eviction, never under-counting that would let the cache
5202/// silently exceed the byte budget.
5203pub(crate) fn pirls_result_cache_bytes(result: &PirlsResult) -> usize {
5204    use std::mem::size_of;
5205    let n_array_elems = result.final_eta.len()
5206        + result.solveweights.len()
5207        + result.solveworking_response.len()
5208        + result.solvemu.len()
5209        + result.solve_c_array.len()
5210        + result.solve_d_array.len();
5211    let p = result.beta_transformed.0.len();
5212    let pen_h = symmetric_matrix_cache_bytes(&result.penalized_hessian_transformed);
5213    let stab_h = symmetric_matrix_cache_bytes(&result.stabilizedhessian_transformed);
5214    let reparam = (result.reparam_result.s_transformed.len()
5215        + result.reparam_result.qs.len()
5216        + result.reparam_result.e_transformed.len()
5217        + result.reparam_result.det1.len())
5218        * size_of::<f64>();
5219    n_array_elems * size_of::<f64>() + p * size_of::<f64>() + pen_h + stab_h + reparam + 1024
5220}
5221
5222pub(crate) fn symmetric_matrix_cache_bytes(m: &gam_linalg::matrix::SymmetricMatrix) -> usize {
5223    use gam_linalg::matrix::SymmetricMatrix;
5224    use std::mem::size_of;
5225    match m {
5226        SymmetricMatrix::Dense(a) => a.len() * size_of::<f64>(),
5227        SymmetricMatrix::Sparse(s) => {
5228            // CSC sparse: f64 values + usize row indices + usize column pointers.
5229            let (symbolic, values) = s.parts();
5230            values.len() * (size_of::<f64>() + size_of::<usize>())
5231                + std::mem::size_of_val(symbolic.col_ptr())
5232        }
5233    }
5234}
5235
5236/// Capacity (number of distinct rho-points) of the outer-eval reuse LRU.
5237///
5238/// Sized to comfortably span a binomial seed grid's local revisit window
5239/// (baseline + isotropic shifts + per-axis refinements) plus a few
5240/// line-search trial points without unbounded growth. Each slot holds one
5241/// `OuterEval` (a scalar cost, a length-k gradient, an optional inner-beta
5242/// hint, and a usually-`Unavailable` Hessian), so the footprint is tiny.
5243pub(crate) const OUTER_EVAL_LRU_CAPACITY: usize = 8;
5244
5245/// Bounded least-recently-used cache of converged outer REML evaluations,
5246/// keyed by sanitized rho-bits plus the active inner-solve caps.
5247///
5248/// CORRECTNESS: the key starts with `Vec<u64>` of `f64::to_bits` (with ±0
5249/// canonicalized) and appends the screening and outer P-IRLS caps. Every
5250/// other input to `OuterEval` — design matrix, prior weights, offset, penalty
5251/// structure, link/SAS/mixture state, Firth/Jeffreys configuration, and the
5252/// rho-prior — is immutable for the lifetime of the state that owns the cache.
5253/// Inner fidelity is not immutable: search-time caps deliberately change it.
5254/// Including those caps prevents a full-fidelity terminal request from
5255/// replaying a coarse search entry at the same rho. Distinct rho/fidelity
5256/// states never alias because lookups compare the full key vector.
5257pub(crate) struct OuterEvalLru {
5258    capacity: usize,
5259    /// Front = least-recently-used, back = most-recently-used.
5260    entries: std::collections::VecDeque<(Vec<u64>, OuterEval)>,
5261}
5262
5263impl OuterEvalLru {
5264    pub(crate) fn new(capacity: usize) -> Self {
5265        Self {
5266            capacity: capacity.max(1),
5267            entries: std::collections::VecDeque::new(),
5268        }
5269    }
5270
5271    /// Returns a clone of the eval stored under `key`, if present, promoting it
5272    /// to most-recently-used. A miss returns `None` so the caller recomputes —
5273    /// never a stale value from a different key.
5274    pub(crate) fn get(&mut self, key: &[u64]) -> Option<OuterEval> {
5275        let pos = self.entries.iter().position(|(k, _)| k.as_slice() == key)?;
5276        let entry = self.entries.remove(pos)?;
5277        let eval = entry.1.clone();
5278        self.entries.push_back(entry);
5279        Some(eval)
5280    }
5281
5282    /// Inserts (or refreshes) the eval for `key` as most-recently-used,
5283    /// evicting the least-recently-used entry once capacity is exceeded.
5284    pub(crate) fn insert(&mut self, key: Vec<u64>, eval: OuterEval) {
5285        if let Some(pos) = self
5286            .entries
5287            .iter()
5288            .position(|(k, _)| k.as_slice() == key.as_slice())
5289        {
5290            self.entries.remove(pos);
5291        }
5292        self.entries.push_back((key, eval));
5293        while self.entries.len() > self.capacity {
5294            self.entries.pop_front();
5295        }
5296    }
5297
5298    pub(crate) fn clear(&mut self) {
5299        self.entries.clear();
5300    }
5301}
5302
5303/// Centralized cache/memoization owner for REML evaluations.
5304///
5305/// This keeps cache-key identity, bundle reuse, and invalidation policy out of
5306/// the math kernels so objective/derivative routines can stay algebra-focused.
5307pub(crate) struct EvalCacheManager {
5308    pub(crate) pirls_cache: RwLock<PirlsLruCache>,
5309    pub(crate) penalty_subspace_cache: RwLock<PenaltySubspaceCache>,
5310    pub(crate) current_eval_bundle: RwLock<Option<EvalShared>>,
5311    /// Most-recently-*stored* outer eval (single slot). Retained verbatim so
5312    /// `previous_outer_gradient_norm` keeps its exact "immediately previous
5313    /// distinct eval" semantics, independent of the multi-slot reuse cache.
5314    pub(crate) current_outer_eval: RwLock<Option<(Vec<u64>, OuterEval)>>,
5315    /// Bounded multi-slot LRU of converged outer evaluations keyed by the
5316    /// sanitized rho-bits and the active inner-solve caps (#1575/#2309).
5317    ///
5318    /// For a frozen `RemlState` (fixed design, prior weights, offset, penalty
5319    /// structure, link state, Firth/Jeffreys configuration, and rho-prior — all
5320    /// of which are immutable for the lifetime of the state that owns this
5321    /// manager and therefore the lifetime of the cache), the remaining
5322    /// result-determining state is `(rho, screening_cap, outer_cap)`. The cap
5323    /// suffix is essential because a search-time partial inner mode and the
5324    /// terminal uncapped mode can share bit-identical rho.
5325    /// The binomial REML fit performs ~20-32 seed-grid pre-solves plus
5326    /// line-search revisits; with only the single `current_outer_eval` slot,
5327    /// any revisit to an earlier rho re-ran a full n-sized P-IRLS. This LRU
5328    /// returns the stored cost/gradient for those revisited rho-points.
5329    pub(crate) outer_eval_lru: RwLock<OuterEvalLru>,
5330    pub(crate) pirls_cache_enabled: AtomicBool,
5331}
5332
5333impl EvalCacheManager {
5334    pub(crate) fn new() -> Self {
5335        Self {
5336            pirls_cache: RwLock::new(PirlsLruCache::new(PIRLS_CACHE_BYTE_BUDGET)),
5337            penalty_subspace_cache: RwLock::new(PenaltySubspaceCache::new()),
5338            current_eval_bundle: RwLock::new(None),
5339            current_outer_eval: RwLock::new(None),
5340            outer_eval_lru: RwLock::new(OuterEvalLru::new(OUTER_EVAL_LRU_CAPACITY)),
5341            pirls_cache_enabled: AtomicBool::new(true),
5342        }
5343    }
5344
5345    /// Memoizing wrapper for `PenaltySubspace` construction.
5346    ///
5347    /// The penalty-subspace eigendecomposition is shape-invariant: any two
5348    /// outer evaluations at the same `(E_transformed, ridge_passport)` produce
5349    /// bit-identical subspaces. The single-slot cache amortizes consecutive
5350    /// fixed-S queries (rank, logdet, trace) within a single outer iter.
5351    pub(super) fn cached_penalty_subspace<F>(
5352        &self,
5353        e_transformed: &ndarray::Array2<f64>,
5354        ridge_passport: &gam_problem::RidgePassport,
5355        build: F,
5356    ) -> Result<Arc<outer_eval::PenaltySubspace>, EstimationError>
5357    where
5358        F: FnOnce() -> Result<outer_eval::PenaltySubspace, EstimationError>,
5359    {
5360        let key = PenaltySubspaceCacheKey::from_inputs(e_transformed, ridge_passport);
5361        if let Some(hit) = self.penalty_subspace_cache.read().unwrap().get(&key) {
5362            return Ok(hit);
5363        }
5364        let value = Arc::new(build()?);
5365        self.penalty_subspace_cache
5366            .write()
5367            .unwrap()
5368            .insert(key, value.clone());
5369        Ok(value)
5370    }
5371
5372    pub(crate) fn cached_eval_bundle(&self, key: &Option<Vec<u64>>) -> Option<EvalShared> {
5373        let guard = self.current_eval_bundle.read().unwrap();
5374        let bundle: &EvalShared = guard.as_ref()?;
5375        bundle.matches(key).then(|| bundle.clone())
5376    }
5377
5378    pub(crate) fn store_eval_bundle(&self, bundle: EvalShared) {
5379        *self.current_eval_bundle.write().unwrap() = Some(bundle);
5380    }
5381
5382    pub(crate) fn cached_outer_eval(&self, key: &Option<Vec<u64>>) -> Option<OuterEval> {
5383        let key = key.as_ref()?;
5384        // The LRU is the authoritative multi-slot store; it always contains the
5385        // most-recently-stored eval too (kept in sync by `store_outer_eval`), so
5386        // a single LRU probe subsumes the old single-slot fast path while also
5387        // serving revisited (non-immediate) rho-points. `get` is a tiny linear
5388        // scan (capacity is `OUTER_EVAL_LRU_CAPACITY`) that promotes the hit to
5389        // most-recently-used; hence the write lock.
5390        self.outer_eval_lru.write().unwrap().get(key)
5391    }
5392
5393    pub(crate) fn store_outer_eval(&self, key: &Option<Vec<u64>>, eval: &OuterEval) {
5394        if let Some(key) = key.clone() {
5395            // Keep the single-slot mirror for `previous_outer_gradient_norm`,
5396            // whose "immediately previous distinct eval" contract reads it
5397            // directly and must stay byte-for-byte unchanged.
5398            *self.current_outer_eval.write().unwrap() = Some((key.clone(), eval.clone()));
5399            self.outer_eval_lru
5400                .write()
5401                .unwrap()
5402                .insert(key, eval.clone());
5403        }
5404    }
5405
5406    pub(crate) fn invalidate_eval_bundle(&self) {
5407        self.current_eval_bundle.write().unwrap().take();
5408        self.current_outer_eval.write().unwrap().take();
5409        self.outer_eval_lru.write().unwrap().clear();
5410    }
5411
5412    pub(crate) fn clear_eval_and_factor_caches(&self) {
5413        self.invalidate_eval_bundle();
5414        self.penalty_subspace_cache.write().unwrap().clear();
5415    }
5416}
5417
5418/// Reusable scratch/runtime memory that should not be part of mathematical
5419/// state invariants.
5420pub(crate) struct RemlArena {
5421    pub(crate) cost_eval_count: RwLock<u64>,
5422    /// Number of *actual* full-n inner P-IRLS solves performed (#1575).
5423    ///
5424    /// Distinct from `cost_eval_count`, which counts every outer cost/gradient
5425    /// REQUEST including single-slot cache hits and prior short-circuits. This
5426    /// counts only the cache-missing `prepare_eval_bundlewithkey` calls — i.e.
5427    /// the genuinely expensive `O(n·p²)` inner solves the #1575 slowdown is
5428    /// about ("~150 outer cost evals each running a full n-sized P-IRLS"). A
5429    /// healthy warm-started fit performs roughly 2 inner solves per outer
5430    /// cost-eval (one value, one gradient/Hessian), so a large ratio between
5431    /// the two signals broken warm-starting or duplicate solving. This is pure
5432    /// observability: it never feeds back into the optimization and changes no
5433    /// fitted value.
5434    pub(crate) inner_pirls_solve_count: AtomicU64,
5435    pub(crate) lastgradient_used_stochastic_fallback: AtomicBool,
5436}
5437
5438impl RemlArena {
5439    pub(crate) fn new() -> Self {
5440        Self {
5441            cost_eval_count: RwLock::new(0),
5442            inner_pirls_solve_count: AtomicU64::new(0),
5443            lastgradient_used_stochastic_fallback: AtomicBool::new(false),
5444        }
5445    }
5446}
5447
5448pub(crate) struct RemlState<'a> {
5449    pub(crate) y: ArrayView1<'a, f64>,
5450    pub(crate) x: DesignMatrix,
5451    pub(crate) weights: ArrayView1<'a, f64>,
5452    pub(crate) offset: Array1<f64>,
5453    /// Canonicalized block-local penalties with pre-computed roots.
5454    /// This is the single canonical penalty representation — no full-width
5455    /// `rank × p` roots are stored separately.
5456    pub(crate) canonical_penalties: Arc<Vec<gam_terms::construction::CanonicalPenalty>>,
5457    pub(crate) balanced_penalty_root: Array2<f64>,
5458    pub(crate) reparam_invariant: ReparamInvariant,
5459    pub(crate) sparse_penalty_block_count: Option<usize>,
5460    pub(crate) p: usize,
5461    pub(crate) config: Arc<RemlConfig>,
5462    pub(crate) runtime_mixture_link_state: Option<gam_problem::MixtureLinkState>,
5463    pub(crate) runtime_sas_link_state: Option<SasLinkState>,
5464    pub(crate) nullspace_dims: Vec<usize>,
5465    pub(crate) coefficient_lower_bounds: Option<Array1<f64>>,
5466    pub(crate) linear_constraints: Option<crate::pirls::LinearInequalityConstraints>,
5467    /// Relative shrinkage floor for penalized block eigenvalues (rho-independent).
5468    pub(crate) penalty_shrinkage_floor: Option<f64>,
5469    /// Explicit prior on log smoothing parameters used by the REML/LAML objective.
5470    pub(crate) rho_prior: gam_problem::RhoPrior,
5471
5472    pub(crate) cache_manager: EvalCacheManager,
5473    pub(crate) arena: RemlArena,
5474    pub(crate) warm_start_beta: RwLock<Option<Coefficients>>,
5475    /// Two-point ρ-trajectory used for second-order warm-start
5476    /// extrapolation: when the outer optimizer asks for a fit at a new
5477    /// ρ, we have `β(ρ_k)` (in `warm_start_beta`) and `β(ρ_{k-1})` (in
5478    /// `prev_warm_start_beta`). The implicit β(ρ) trajectory is locally
5479    /// linear under the FOC ∇F(β,ρ)=0, so a tangent-line prediction
5480    /// `β_predict(ρ_new) = β_k + α · (β_k − β_{k-1})` where α is the
5481    /// projection of `(ρ_new − ρ_k)` onto `(ρ_k − ρ_{k-1})` gives a
5482    /// better seed than the flat `β_k` alone — replacing PIRLS warm-
5483    /// start "use last β as-is" with a real tangent-prediction step.
5484    pub(crate) warm_start_rho: RwLock<Option<Array1<f64>>>,
5485    pub(crate) prev_warm_start_beta: RwLock<Option<Coefficients>>,
5486    pub(crate) prev_warm_start_rho: RwLock<Option<Array1<f64>>>,
5487    pub(crate) warm_start_enabled: AtomicBool,
5488    pub(crate) screening_max_inner_iterations: Arc<AtomicUsize>,
5489    /// Outer-aware inner-PIRLS iteration cap for the main descent loop.
5490    ///
5491    /// Distinct from `screening_max_inner_iterations`, which is used during
5492    /// seed selection and toggles a side-effect bundle (cache writes,
5493    /// warm-start updates, KKT enforcement all suppressed). This atomic is
5494    /// purely a cap — when nonzero, the inner Newton loop is capped at
5495    /// `min(this, full_max_iterations)`, but cache writes and warm-start
5496    /// updates remain enabled. Driven by the outer optimizer to coarsen
5497    /// inner solves at early outer iterations when ρ is far from converged,
5498    /// and lifted back to full at the final accepted iter (otherwise the
5499    /// returned β would be biased by the loose cap).
5500    ///
5501    /// Both atomics are honored together as `min(screening_cap, outer_cap)`
5502    /// when both are nonzero. Default 0 (no cap from this source).
5503    pub(crate) outer_inner_cap: Arc<AtomicUsize>,
5504
5505    /// Inner-PIRLS feedback signal driven by `execute_pirls_if_needed` after
5506    /// each NON-screening solve. Stores the iteration count at which the
5507    /// inner Newton stopped, plus a flag indicating whether it converged
5508    /// (vs. hit the iteration cap). The outer first-/second-order bridges
5509    /// read these atomics to drive an adaptive `inner_cap_schedule`: the
5510    /// next outer iter's inner cap becomes `last_iters + small_margin`
5511    /// when the previous solve converged, or a geometric backoff when it
5512    /// hit the cap. This replaces the older hardcoded iter-tier schedule
5513    /// (3/5/10/20) with a cap that follows the inner solver's actual
5514    /// convergence behavior — Eisenstat-Walker style for the inner
5515    /// quadratic loop. Default 0 / false (no signal yet — first outer
5516    /// iter falls back to a coarse iter-count tier).
5517    pub(crate) last_inner_iters: Arc<AtomicUsize>,
5518    pub(crate) last_inner_converged: Arc<AtomicBool>,
5519
5520    /// Cached state from the most recent successful PIRLS solve, used by
5521    /// the IFT-based warm-start predictor.
5522    ///
5523    /// The implicit-function theorem applied to the FOC ∇_β F(β,ρ)=0
5524    /// gives `dβ/dρ_k = -H_pen^{-1} · (e^{ρ_k} · S_k · β)`. A first-order
5525    /// Taylor predictor reads
5526    /// `β_predict(ρ_new) = β_cur − Σ_k Δρ_k · H_pen^{-1} · (e^{ρ_cur_k} · S_k · β_cur)`.
5527    /// This is a strict superset of the tangent-line predictor's
5528    /// requirements: works after a single successful solve (tangent-line
5529    /// needs two prior fits), and gives the EXACT first-order Jacobian
5530    /// of the implicit β(ρ) trajectory rather than a secant proxy along one
5531    /// ρ-direction.
5532    ///
5533    /// Populated in `updatewarm_start_from` when PIRLS converges; cleared
5534    /// on failure, on `reset_surface`, and on link-state changes.
5535    pub(crate) ift_warm_start_cache: RwLock<Option<IftWarmStartCache>>,
5536
5537    /// Persisted Levenberg-Marquardt damping coefficient from the most
5538    /// recent successful PIRLS solve, bit-packed into an `AtomicU64`
5539    /// (`f64::to_bits` low 64 bits). Read at the start of
5540    /// `execute_pirls_if_needed` and written into the
5541    /// `PirlsConfig::initial_lm_lambda` hint so the inner Newton seeds
5542    /// `λ_LM` near the damping the previous solve discovered, instead
5543    /// of cold-starting at `1e-6` and burning 4-6 halving steps to
5544    /// recover. `0` (the default) signals "no hint"; the inner solver
5545    /// clamps any positive hint into `[1e-6, 1e-3]` so a stale value
5546    /// cannot destabilize the next solve. Reset on `reset_surface` and
5547    /// on failed solves.
5548    pub(crate) last_pirls_lm_lambda: Arc<AtomicU64>,
5549
5550    /// Negative-Binomial overdispersion `theta` frozen for the smoothing-
5551    /// parameter (λ) search (#1082), bit-packed `f64` (`f64::to_bits`). `0`
5552    /// (the default) signals "not yet frozen". On the first non-screening
5553    /// λ-search inner solve of an estimated-θ NB fit, the seed's
5554    /// maximum-likelihood θ is computed once and stored here; every subsequent
5555    /// λ-search evaluation pins the inner solve to this value via
5556    /// `GlmLikelihoodSpec::with_negbin_theta_frozen_for_search`, so the REML
5557    /// criterion `F(ρ) = REML(ρ, θ_frozen)` is a stationary function of ρ and
5558    /// the outer optimizer converges instead of chasing the per-eval θ drift
5559    /// that the estimated path injects. The single final reported fit still
5560    /// ML-refreshes θ at the converged η. Reset on `reset_surface`.
5561    pub(crate) frozen_negbin_theta: Arc<AtomicU64>,
5562
5563    /// Tweedie exponential-dispersion `phi` frozen for the smoothing-parameter
5564    /// (λ) search (#1477), bit-packed `f64` (`f64::to_bits`). `0` (the default)
5565    /// signals "not yet frozen". On the first non-screening λ-search inner solve
5566    /// of an estimated-φ Tweedie fit, the seed's Pearson `phî` is captured once
5567    /// and stored here; every subsequent λ-search evaluation pins the inner
5568    /// solve to this value via
5569    /// `GlmLikelihoodSpec::with_tweedie_phi_frozen_for_search`, so the REML
5570    /// criterion `F(ρ) = REML(ρ, φ_frozen)` is a stationary function of ρ. The
5571    /// Tweedie LAML omits the `phi`-dependent saddlepoint normalizer, so a `phi`
5572    /// drifting with each warm-start η lets the criterion reward dispersion
5573    /// inflation and rail a double-penalty null-space `λ` to the box bound (the
5574    /// #1477 boundary blow-up). The single final reported fit still
5575    /// Pearson-refreshes `phi` at the converged η. Reset on `reset_surface`.
5576    pub(crate) frozen_tweedie_phi: Arc<AtomicU64>,
5577
5578    /// Gamma shape `k = 1/φ` frozen for the smoothing-parameter (λ) search
5579    /// (#1074), bit-packed `f64` (`f64::to_bits`). `0` (the default) signals
5580    /// "not yet frozen". On the first non-screening λ-search inner solve of an
5581    /// estimated-shape Gamma fit, the seed's converged-η MLE `k̂` is captured
5582    /// once and stored here; every subsequent λ-search evaluation pins the inner
5583    /// solve to this value via
5584    /// `GlmLikelihoodSpec::with_gamma_shape_frozen_for_search`, so the REML
5585    /// criterion `F(ρ) = REML(ρ, k_frozen)` is a stationary function of ρ. With
5586    /// `k` estimated the inner solver re-derives it from each warm-start η, and
5587    /// because the Gamma working weight is `W = prior·k` and the
5588    /// omitting-constants log-likelihood is `−k·½D`, a `k` swinging with η makes
5589    /// BOTH the curvature `H = k·XᵀX + λS` and the data-fit `k·½D` jump with ρ —
5590    /// the criterion grows deterministic spikes that floor the projected
5591    /// gradient and rail `λ` to the over-smoothed corner (the #1074 te/Gamma
5592    /// tensor under-recovery). The single final reported fit still ML-refreshes
5593    /// `k` at the converged η. Reset on `reset_surface`.
5594    pub(crate) frozen_gamma_shape: Arc<AtomicU64>,
5595
5596    /// Beta-regression precision `phi` frozen for the smoothing-parameter (λ)
5597    /// search (#2369), bit-packed `f64` (`f64::to_bits`). `0` (the default)
5598    /// signals "not yet frozen". On the first non-screening λ-search inner solve
5599    /// of an estimated-φ Beta fit, the seed's converged-η Pearson `phî` is
5600    /// captured once and stored here; every subsequent λ-search evaluation pins
5601    /// the inner solve to this value via
5602    /// `GlmLikelihoodSpec::with_beta_phi_frozen_for_search`, so the REML
5603    /// criterion `F(ρ) = REML(ρ, φ_frozen)` is a stationary function of ρ. With
5604    /// `phi` estimated the inner solver re-derives it from each warm-start η, and
5605    /// because the Beta precision does not factor out of the digamma mean score
5606    /// `∂ℓ/∂β = φ·Σ xᵢ(y*ᵢ − μ*ᵢ)`, a `phi` swinging with η makes both β̂(ρ) and
5607    /// the REML data-fit / log-det terms jump with ρ while the analytic outer
5608    /// gradient holds `phi` fixed — the projected gradient floors above tolerance
5609    /// and the optimizer refuses ("NOT STATIONARY"), the family-unusable #2369
5610    /// signature, identical to the sibling Gamma/Tweedie/NB drift. The single
5611    /// final reported fit still Pearson-refreshes `phi` at the converged η. Reset
5612    /// on `reset_surface`.
5613    pub(crate) frozen_beta_phi: Arc<AtomicU64>,
5614
5615    /// Last observed IFT-prediction residual (`‖β_converged − β_predicted‖
5616    /// / ‖β_converged‖`) from the most recent non-screening solve where
5617    /// the predictor was actually consumed. Bit-packed `f64` (low 64
5618    /// bits via `f64::to_bits`).
5619    ///
5620    /// "No signal yet" is encoded as a NaN bit-pattern
5621    /// (`IFT_RESIDUAL_NO_SIGNAL_BITS`). The original `0` sentinel
5622    /// collided with `f64::to_bits(0.0) == 0` — a true residual of
5623    /// exactly 0 (degenerate but mathematically possible if every
5624    /// β_predicted_i matched β_converged_i to bit-equality) would
5625    /// have been indistinguishable from "predictor never reported".
5626    /// NaN's self-inequality makes the sentinel unambiguous: any
5627    /// stored finite non-negative value is genuine signal.
5628    ///
5629    /// Read by `predict_warm_start_beta_ift_with_outcome` to drive the adaptive
5630    /// |Δρ| cap (`adaptive_ift_max_drho`): a small residual loosens
5631    /// the cap, a large one tightens it. Replaces the previous
5632    /// hardcoded `IFT_WARM_START_MAX_DRHO = 2.0` constant with a
5633    /// data-driven policy, so the predictor adapts to the empirical
5634    /// faithfulness of the linearization at this surface's scale.
5635    /// Reset on `reset_surface` and on failed solves.
5636    pub(crate) last_ift_prediction_residual: Arc<AtomicU64>,
5637
5638    /// Last observed gain ratio of the accepted LM step
5639    /// (`actual_reduction / predicted_reduction`) from the most recent
5640    /// non-screening PIRLS solve. Bit-packed `f64` with the same NaN
5641    /// sentinel discipline as `last_ift_prediction_residual`: NaN bits
5642    /// (`IFT_RESIDUAL_NO_SIGNAL_BITS`) encode "no signal yet" so a
5643    /// recorded ratio of exactly 0 (degenerate but possible) doesn't
5644    /// collide with the no-signal token.
5645    ///
5646    /// Used by `first_order_inner_cap_schedule` as a third quality
5647    /// signal alongside `last_iters` and `last_converged`. A small
5648    /// `accept_rho` (model overstating predicted reduction) is a hint
5649    /// the next iter's inner Newton may need extra margin even when
5650    /// the previous solve converged in few iters. Reset on
5651    /// `reset_surface` and on failed solves.
5652    pub(crate) last_pirls_accept_rho: Arc<AtomicU64>,
5653
5654    /// Cached Cholesky factorization of `IftWarmStartCache::penalized_hessian_transformed`.
5655    /// Lazily computed on the first IFT predict call after a fresh
5656    /// `updatewarm_start_from`, then reused by every subsequent
5657    /// predict call until the IFT cache is invalidated. At large-scale
5658    /// scale where p can reach several thousand, the dense Cholesky
5659    /// is O(p³)/3 — multiple seconds per refactor — so caching saves
5660    /// real wall time across the typical 5-10 IFT predict calls per
5661    /// outer fit. Reset jointly with `ift_warm_start_cache` (on
5662    /// reset_surface, on link-state changes, on failed PIRLS solves,
5663    /// and whenever a new H_pen replaces the cached one).
5664    pub(crate) ift_cached_factor: RwLock<Option<Arc<dyn gam_linalg::matrix::FactorizedSystem>>>,
5665
5666    /// When set, the penalties have Kronecker (tensor-product) structure and
5667    /// the REML evaluator can use O(∏q_j) logdet instead of O(p³) eigendecomposition.
5668    /// Populated via `set_kronecker_penalty_system` after construction.
5669    pub(crate) kronecker_penalty_system: Option<gam_terms::smooth::KroneckerPenaltySystem>,
5670    /// Full Kronecker factored basis (marginal designs + penalties + dims).
5671    /// Used by P-IRLS for factored reparameterization.
5672    pub(crate) kronecker_factored: Option<gam_terms::basis::KroneckerFactoredBasis>,
5673
5674    /// Precomputed `(XᵀWX, XᵀW(y − offset))` for the Gaussian + Identity
5675    /// outer REML loop, populated once before the outer optimizer when the
5676    /// family / link / constraint preconditions hold and the design supports
5677    /// the Identity short-circuit at `pirls.rs:6237`. When present, each
5678    /// inner `solve_penalized_least_squares_implicit` reads these matrices
5679    /// instead of restreaming the O(N·p²) GEMM and O(N·p) matvec per outer
5680    /// iteration — the penalty `λ·S` is still added per-λ.
5681    ///
5682    /// Invalidated jointly with the design in `reset_surface`.
5683    pub(crate) gaussian_fixed_cache: RwLock<Option<Arc<crate::pirls::GaussianFixedCache>>>,
5684    /// Conditioned-frame exact ψ-derivatives `(∂XᵀWX/∂ψ, ∂XᵀW(y−offset)/∂ψ)`
5685    /// for the SINGLE design-moving spatial hyperparameter (#1033b), assembled
5686    /// n-free from the certified Chebyshev ψ-Gram tensor and installed beside
5687    /// `gaussian_fixed_cache` at the same in-window trial. When present the
5688    /// Gaussian-identity ψ-gradient HyperCoord (`a_j`, `g_j`, dense `B_j`) is
5689    /// formed from these k×k objects instead of realizing and contracting the
5690    /// n×k ∂X/∂ψ slab — retiring the second per-trial n-pass. Lives in the
5691    /// SAME conditioned column frame as `gaussian_fixed_cache.xtwx_orig`, so
5692    /// the hyper-coord builder transforms it by the per-eval Qs/free-basis the
5693    /// same way it transforms the streamed Gram. Invalidated with the design.
5694    pub(crate) gaussian_psi_gram_deriv:
5695        RwLock<Option<Arc<(ndarray::Array2<f64>, ndarray::Array1<f64>)>>>,
5696    /// Conditioned-frame exact ψ-derivative pair `(∂XᵀWX/∂ψ, ∂XᵀW(y−offset)/∂ψ)`
5697    /// for the SINGLE design-moving spatial hyperparameter in the GLM (frozen-W)
5698    /// lane (#1033 / #1111), assembled n-free from
5699    /// [`crate::glm_sufficient_lane::FrozenWeightGramTensor::gradient_pair_if_sound`]
5700    /// and installed beside `glm_first_step_gram` at the same in-window
5701    /// drift-OK trial. When present, the GLM ψ-gradient HyperCoord serves its
5702    /// envelope `a_j` and score `g_j` from these k×k objects instead of
5703    /// realizing and contracting the n×k ∂X/∂ψ slab — the second per-trial
5704    /// n-pass. Unlike the Gaussian lane the Hessian curvature `B_j` is NOT
5705    /// served from the tensor: for a GLM the per-trial `B_j` term
5706    /// `X_τᵀWX + XᵀWX_τ` is irreducibly n-dependent (the moving working weight
5707    /// `W` does not factor out of a frozen-W k×k object), so `B_j` keeps the
5708    /// exact streamed slab (#1033). Lives in the SAME conditioned column frame
5709    /// as `glm_first_step_gram` / `gaussian_fixed_cache.xtwx_orig`, so the
5710    /// hyper-coord builder transforms it by the per-eval Qs/free-basis the same
5711    /// way. NOT family-gated (the GLM lane's own slot). Invalidated with the
5712    /// design.
5713    pub(crate) glm_psi_gram_deriv:
5714        RwLock<Option<Arc<(ndarray::Array2<f64>, ndarray::Array1<f64>)>>>,
5715    /// Frozen-weight first-Fisher-step data-fit Gram `XᵀWX` for the GLM
5716    /// design-moving ψ-sweep (#1111 / #1033 mechanism (c)), in the conditioned
5717    /// (original / `x_fit`) column frame — the SAME frame as
5718    /// `gaussian_fixed_cache.xtwx_orig`.
5719    ///
5720    /// Assembled n-free per in-window ψ-trial from the certified frozen-weight
5721    /// Chebyshev tensor ([`crate::glm_sufficient_lane::FrozenWeightGramTensor`])
5722    /// and installed only when the trial's converged working weight has not
5723    /// drifted past tolerance from the frozen snapshot. When present, the GLM
5724    /// inner P-IRLS serves its FIRST Fisher-scoring iteration's `XᵀWX` from this
5725    /// cache instead of restreaming the O(N·p²) weighted cross-product — the
5726    /// dominant per-trial n-term in a large-n Poisson/Binomial κ-sweep. The
5727    /// penalty `Sλ` is still added per-λ on top, and every subsequent inner
5728    /// iteration restreams the true (moving) `W`, so the converged β̂ is
5729    /// unchanged; only the first-iteration Gram build is elided. Unlike
5730    /// `gaussian_fixed_cache` this is NOT family-gated — it is the GLM lane's
5731    /// own slot, consumed once per inner solve. Invalidated with the design in
5732    /// `reset_surface`.
5733    pub(crate) glm_first_step_gram: RwLock<Option<Arc<ndarray::Array2<f64>>>>,
5734    /// Previous successful non-Gaussian fixed-design data-fit Gram `XᵀWX` in
5735    /// the conditioned original frame, keyed to `warm_start_beta`.
5736    ///
5737    /// When the next outer trial uses a flat warm start, its first PIRLS
5738    /// curvature build evaluates at the same `η = Xβ` as the previous converged
5739    /// solve, so the Hessian weights and `XᵀWX` are identical. Reusing this
5740    /// original-frame Gram skips one dense `O(n·p²)` pass per warm-started
5741    /// trial while still letting later PIRLS iterations restream the moving
5742    /// weights. IFT/tangent-predicted starts do not consume this cache.
5743    ///
5744    /// The cached dense `p×p` Gram is coupled to a [`MemoryGovernor`] ledger
5745    /// reservation ([`gam_runtime::resource::Governed`]) so a long-lived cache
5746    /// entry never holds dense bytes off-ledger; a reservation refusal under
5747    /// joint pressure skips caching (the streamed path remains available).
5748    pub(crate) flat_glm_first_step_gram:
5749        RwLock<Option<gam_runtime::resource::Governed<Arc<ndarray::Array2<f64>>>>>,
5750    /// Stable disk-cache key for the current realized REML surface. Computed
5751    /// lazily because it hashes the row-chunked design and data vectors.
5752    pub(crate) persistent_warm_start_key: RwLock<Option<String>>,
5753    pub(crate) persistent_latent_values_fingerprint: Option<u64>,
5754    pub(crate) persistent_latent_values_cache: RwLock<PersistentLatentValuesCache>,
5755    pub(crate) analytic_penalty_registry_fingerprint: u64,
5756    /// Ensures the process attempts at most one disk restore per surface.
5757    pub(crate) persistent_warm_start_loaded: AtomicBool,
5758    /// Scoped counter disabling disk writes from cost-only posterior/probe
5759    /// evaluations. In-memory warm starts still update; only JSON/bin
5760    /// persistence and eviction sweeps are suppressed.
5761    pub(crate) persistent_warm_start_store_suppression: AtomicUsize,
5762    /// Whether the cross-process ON-DISK warm-start layer is engaged at all.
5763    ///
5764    /// Default `false`: the optimizer's IN-MEMORY warm start (the actual
5765    /// speed lever) is always on, but the disk checkpoint — `load_record`
5766    /// at fit start and `store_record` at finalize, each of which opens the
5767    /// shared `WarmStartStore` and pays an eviction/dir scan that is O(cache
5768    /// entries) on a network filesystem — is skipped. Disk persistence has
5769    /// reuse value only ACROSS processes or across repeated identical fits;
5770    /// a single in-process fit (and a fortiori a loop of distinct throwaway
5771    /// fits, e.g. CI-coverage replicates each on different data, #1082/#1114)
5772    /// gets zero benefit from it and pays the per-fit open/scan/save in full.
5773    /// `FitConfig::persist_warm_start_disk` flips this to `true` only when the
5774    /// caller explicitly asks for cross-process / repeat-fit persistence.
5775    pub(crate) persistent_warm_start_disk_enabled: AtomicBool,
5776    /// #1033: memoized fit-invariant O(n) response/weight scalars.
5777    ///
5778    /// `gaussian_weight_log_sum_half` (`½·Σ log wᵢ`) and `gaussian_dp_floor_scale`
5779    /// (the weighted null deviance `D₀`) are pure functions of the borrowed
5780    /// `(y, weights)` — fields `reset_surface` NEVER reassigns — so they are
5781    /// constant for the whole life of the `RemlState`. They were the last O(n)
5782    /// passes the n-free κ outer loop ran on EVERY `assemble_and_evaluate`
5783    /// (each an n-length scalar reduction, no k factor). Memoizing them once per
5784    /// fit makes the per-trial eval touch only k-dim objects, completing the
5785    /// issue's sufficient-statistic invariant literally. Plain scalar closures,
5786    /// no rayon inside the `get_or_init` (no deadlock trap).
5787    pub(crate) gaussian_weight_log_sum_half_cache: std::sync::OnceLock<f64>,
5788    pub(crate) gaussian_dp_floor_scale_cache: std::sync::OnceLock<f64>,
5789}