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