Skip to main content

gam_models/
response_geometry.rs

1//! Exact shared-smoothing Gaussian REML for tangent-vector responses.
2//!
3//! The model has a scalar predictor design `X` (`N x K`) and a tangent
4//! response `Y` (`N x D`).  A coefficient matrix `B` is fitted under
5//!
6//! `sum_i w_i (y_i - B' x_i)' M_i (y_i - B' x_i)
7//!     + sum_b lambda_b tr(B' S_b B)`.
8//!
9//! The implementation deliberately never constructs the stacked
10//! `(N D) x (K D)` design or a `S_b (x) I_D` penalty.  Isotropic metrics use
11//! only `K x K` normal equations.  Varying Fisher metrics stream exact joint
12//! sufficient statistics into a `(K D) x (K D)` Gram matrix whose storage is
13//! independent of `N`.
14
15use faer::Side;
16use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh, fast_xt_diag_x, fast_xt_diag_y};
17use gam_linalg::matrix::{DesignMatrix, LinearOperator};
18use gam_linalg::utils::KahanSum;
19// `DeclaredHessianForm`/`Derivative` originate in `gam_problem` and are only
20// re-exported privately inside `gam_solve::rho_optimizer`; import them from the
21// canonical source, matching every other `gam-models` outer-objective site.
22use gam_problem::{DeclaredHessianForm, Derivative, StationarityStandard};
23use gam_solve::estimate::EstimationError;
24use gam_solve::rho_optimizer::{
25    HessianValue, OuterCapability, OuterCriterionCertificate, OuterEval, OuterObjective,
26    OuterProblem, SeedOutcome,
27};
28use ndarray::{Array1, Array2, Array3, s};
29use serde::{Deserialize, Serialize};
30
31use crate::inference::model::FittedModel;
32
33const FIT_CONTEXT: &str = "shared-tangent Gaussian REML";
34pub const RESPONSE_GEOMETRY_MODEL_VERSION: u32 = 1;
35
36/// One compact predictor-space smoothing penalty.
37///
38/// `matrix` occupies the coefficient columns beginning at `column_start`.
39/// The tangent-output identity factor is implicit and is never materialized.
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct SharedTangentPenalty {
42    pub column_start: usize,
43    pub matrix: Array2<f64>,
44}
45
46impl SharedTangentPenalty {
47    pub fn new(column_start: usize, matrix: Array2<f64>) -> Self {
48        Self {
49            column_start,
50            matrix,
51        }
52    }
53
54    pub fn column_end(&self) -> usize {
55        self.column_start + self.matrix.ncols()
56    }
57}
58
59/// Owned request for a shared-tangent REML fit.
60///
61/// `design` may be dense, sparse, or operator-backed.  The fit consumes it
62/// through bounded row chunks and therefore does not force materialization.
63#[derive(Clone, Debug)]
64pub struct SharedTangentRemlRequest {
65    pub design: DesignMatrix,
66    pub response: Array2<f64>,
67    pub weights: Array1<f64>,
68    pub fisher_metric: Option<Array3<f64>>,
69    pub penalties: Vec<SharedTangentPenalty>,
70    /// Optional log-lambda seed in the original `penalties` order.
71    pub initial_log_lambdas: Option<Array1<f64>>,
72}
73
74impl SharedTangentRemlRequest {
75    pub fn new(
76        design: DesignMatrix,
77        response: Array2<f64>,
78        weights: Array1<f64>,
79        fisher_metric: Option<Array3<f64>>,
80        penalties: Vec<SharedTangentPenalty>,
81    ) -> Self {
82        Self {
83            design,
84            response,
85            weights,
86            fisher_metric,
87            penalties,
88            initial_log_lambdas: None,
89        }
90    }
91
92    /// Convenience constructor for an owned dense design.
93    pub fn from_dense(
94        design: Array2<f64>,
95        response: Array2<f64>,
96        weights: Array1<f64>,
97        fisher_metric: Option<Array3<f64>>,
98        penalties: Vec<SharedTangentPenalty>,
99    ) -> Self {
100        Self::new(
101            DesignMatrix::from(design),
102            response,
103            weights,
104            fisher_metric,
105            penalties,
106        )
107    }
108
109    pub fn with_initial_log_lambdas(mut self, initial: Array1<f64>) -> Self {
110        self.initial_log_lambdas = Some(initial);
111        self
112    }
113}
114
115/// A converged, serializable shared-tangent model.
116///
117/// This type is constructed only after the shared outer runner has produced a
118/// successful analytic stationarity certificate.
119#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct SharedTangentRemlFit {
121    /// Predictor-by-output coefficient matrix (`K x D`).
122    pub coefficients: Array2<f64>,
123    /// Training fitted tangent vectors (`N x D`).
124    pub fitted: Array2<f64>,
125    /// Pooled residual dispersion `Q / (N·D - edf_total)`.
126    pub sigma2: f64,
127    /// Smoothing parameters in the request penalty order.  A numerically
128    /// rank-zero penalty has no estimable smoothing coordinate and is `0`.
129    pub lambdas: Array1<f64>,
130    /// Per-penalty EDF in the request penalty order.
131    pub edf_by_penalty: Array1<f64>,
132    pub edf_total: f64,
133    /// Minimized negative restricted log likelihood.
134    pub reml_score: f64,
135    pub n_observations: usize,
136    pub n_outputs: usize,
137    pub outer_iterations: usize,
138    pub outer_certificate: OuterCriterionCertificate,
139}
140
141impl SharedTangentRemlFit {
142    /// Predict tangent vectors from an operator-capable design.
143    pub fn predict(&self, design: &DesignMatrix) -> Result<Array2<f64>, EstimationError> {
144        predict_from_coefficients(design, &self.coefficients)
145    }
146
147    /// Convenience prediction entry point for an owned dense design.
148    pub fn predict_dense(&self, design: Array2<f64>) -> Result<Array2<f64>, EstimationError> {
149        self.predict(&DesignMatrix::from(design))
150    }
151}
152
153/// Typed curvature-as-estimand record carried by a response-geometry archive.
154#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
155pub struct ResponseGeometryCurvature {
156    pub kappa_hat: f64,
157    pub confidence_level: f64,
158    pub confidence_lower: f64,
159    pub confidence_upper: f64,
160    pub lower_at_bound: bool,
161    pub upper_at_bound: bool,
162    pub verdict: String,
163    pub flatness_likelihood_ratio: f64,
164    pub flatness_p_value: f64,
165    pub railed_at_resolution_limit: bool,
166    pub scale_free_kappa_radius_squared: f64,
167    pub characteristic_radius: f64,
168}
169
170impl ResponseGeometryCurvature {
171    fn validate(&self) -> Result<(), ResponseGeometryModelError> {
172        let finite = [
173            self.kappa_hat,
174            self.confidence_level,
175            self.confidence_lower,
176            self.confidence_upper,
177            self.flatness_likelihood_ratio,
178            self.flatness_p_value,
179            self.scale_free_kappa_radius_squared,
180            self.characteristic_radius,
181        ];
182        if finite.iter().any(|value| !value.is_finite()) {
183            return Err(ResponseGeometryModelError::InvalidMetadata(
184                "curvature record contains non-finite values".to_string(),
185            ));
186        }
187        if !(self.confidence_level > 0.0 && self.confidence_level < 1.0) {
188            return Err(ResponseGeometryModelError::InvalidMetadata(format!(
189                "curvature confidence level must lie in (0, 1), got {}",
190                self.confidence_level
191            )));
192        }
193        if self.confidence_lower > self.confidence_upper {
194            return Err(ResponseGeometryModelError::InvalidMetadata(
195                "curvature confidence interval is reversed".to_string(),
196            ));
197        }
198        if !(self.flatness_likelihood_ratio >= 0.0
199            && (0.0..=1.0).contains(&self.flatness_p_value)
200            && self.characteristic_radius > 0.0)
201        {
202            return Err(ResponseGeometryModelError::InvalidMetadata(
203                "curvature likelihood-ratio, p-value, or characteristic radius is invalid"
204                    .to_string(),
205            ));
206        }
207        if self.verdict.trim().is_empty() {
208            return Err(ResponseGeometryModelError::InvalidMetadata(
209                "curvature verdict must not be empty".to_string(),
210            ));
211        }
212        Ok(())
213    }
214}
215
216/// Persistence and presentation metadata for [`ResponseGeometryModel`].
217#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
218pub struct ResponseGeometryMetadata {
219    pub response_geometry: String,
220    pub response_columns: Vec<String>,
221    pub base_point: Array1<f64>,
222    pub coordinates: String,
223    pub reference: isize,
224    pub training_table_kind: String,
225    pub curvature: Option<ResponseGeometryCurvature>,
226}
227
228/// Core summary of a fitted response-geometry model.
229#[derive(Clone, Debug, Serialize, Deserialize)]
230pub struct ResponseGeometrySummary {
231    pub model_class: String,
232    pub metadata: ResponseGeometryMetadata,
233    pub tangent_dimension: usize,
234    pub shared_smoothing: bool,
235    pub reml_score: f64,
236    pub lambdas: Array1<f64>,
237    pub edf_by_penalty: Array1<f64>,
238    pub edf_total: f64,
239    pub sigma2: f64,
240    pub template_formula: String,
241    pub template_family: String,
242}
243
244/// A complete typed response-geometry model archive.
245///
246/// The scalar template is the native [`FittedModel`] used to reconstruct the
247/// formula design for new data.  The joint tangent coefficients and REML
248/// diagnostics stay in [`SharedTangentRemlFit`]; no opaque Python bytes,
249/// base64, or Python-side matrix multiplication are part of this format.
250#[derive(Clone, Serialize, Deserialize)]
251pub struct ResponseGeometryModel {
252    pub version: u32,
253    pub template_model: FittedModel,
254    pub metadata: ResponseGeometryMetadata,
255    pub shared_tangent_fit: SharedTangentRemlFit,
256}
257
258#[derive(Debug, thiserror::Error)]
259pub enum ResponseGeometryModelError {
260    #[error("invalid response-geometry metadata: {0}")]
261    InvalidMetadata(String),
262    #[error("invalid response-geometry template model: {0}")]
263    InvalidTemplate(String),
264    #[error("response-geometry archive serialization failed: {0}")]
265    Serialization(String),
266}
267
268impl ResponseGeometryModel {
269    pub fn new(
270        template_model: FittedModel,
271        metadata: ResponseGeometryMetadata,
272        shared_tangent_fit: SharedTangentRemlFit,
273    ) -> Result<Self, ResponseGeometryModelError> {
274        let model = Self {
275            version: RESPONSE_GEOMETRY_MODEL_VERSION,
276            template_model,
277            metadata,
278            shared_tangent_fit,
279        };
280        model.validate()?;
281        Ok(model)
282    }
283
284    pub fn validate(&self) -> Result<(), ResponseGeometryModelError> {
285        if self.version != RESPONSE_GEOMETRY_MODEL_VERSION {
286            return Err(ResponseGeometryModelError::InvalidMetadata(format!(
287                "archive version {} does not match required version {}",
288                self.version, RESPONSE_GEOMETRY_MODEL_VERSION
289            )));
290        }
291        if self.metadata.response_geometry.trim().is_empty()
292            || self.metadata.coordinates.trim().is_empty()
293        {
294            return Err(ResponseGeometryModelError::InvalidMetadata(
295                "geometry and coordinate-chart labels must not be empty".to_string(),
296            ));
297        }
298        if self.metadata.response_columns.is_empty()
299            || self
300                .metadata
301                .response_columns
302                .iter()
303                .any(|column| column.trim().is_empty())
304        {
305            return Err(ResponseGeometryModelError::InvalidMetadata(
306                "response columns must be non-empty names".to_string(),
307            ));
308        }
309        let unique: std::collections::HashSet<&str> = self
310            .metadata
311            .response_columns
312            .iter()
313            .map(String::as_str)
314            .collect();
315        if unique.len() != self.metadata.response_columns.len() {
316            return Err(ResponseGeometryModelError::InvalidMetadata(
317                "response columns must be unique".to_string(),
318            ));
319        }
320        if self.metadata.base_point.is_empty()
321            || self
322                .metadata
323                .base_point
324                .iter()
325                .any(|value| !value.is_finite())
326        {
327            return Err(ResponseGeometryModelError::InvalidMetadata(
328                "base point must be non-empty and finite".to_string(),
329            ));
330        }
331        if self.metadata.training_table_kind.trim().is_empty() {
332            return Err(ResponseGeometryModelError::InvalidMetadata(
333                "training table kind must be non-empty".to_string(),
334            ));
335        }
336        if let Some(curvature) = self.metadata.curvature.as_ref() {
337            curvature.validate()?;
338        }
339        validate_archived_tangent_fit(&self.shared_tangent_fit)?;
340        self.template_model
341            .validate_for_persistence()
342            .map_err(|error| ResponseGeometryModelError::InvalidTemplate(error.to_string()))?;
343        self.template_model
344            .validate_numeric_finiteness()
345            .map_err(|error| ResponseGeometryModelError::InvalidTemplate(error.to_string()))?;
346        Ok(())
347    }
348
349    /// Serialize the complete typed archive as UTF-8 JSON bytes.
350    pub fn to_bytes(&self) -> Result<Vec<u8>, ResponseGeometryModelError> {
351        self.validate()?;
352        serde_json::to_vec(self)
353            .map_err(|error| ResponseGeometryModelError::Serialization(error.to_string()))
354    }
355
356    /// Restore and validate a complete typed archive from UTF-8 JSON bytes.
357    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ResponseGeometryModelError> {
358        let model: Self = serde_json::from_slice(bytes)
359            .map_err(|error| ResponseGeometryModelError::Serialization(error.to_string()))?;
360        model.validate()?;
361        Ok(model)
362    }
363
364    pub fn metadata(&self) -> ResponseGeometryMetadata {
365        self.metadata.clone()
366    }
367
368    pub fn summary(&self) -> ResponseGeometrySummary {
369        let payload = self.template_model.payload();
370        ResponseGeometrySummary {
371            model_class: "response-geometry".to_string(),
372            metadata: self.metadata.clone(),
373            tangent_dimension: self.shared_tangent_fit.coefficients.ncols(),
374            shared_smoothing: true,
375            reml_score: self.shared_tangent_fit.reml_score,
376            lambdas: self.shared_tangent_fit.lambdas.clone(),
377            edf_by_penalty: self.shared_tangent_fit.edf_by_penalty.clone(),
378            edf_total: self.shared_tangent_fit.edf_total,
379            sigma2: self.shared_tangent_fit.sigma2,
380            template_formula: payload.formula.clone(),
381            template_family: payload.family.clone(),
382        }
383    }
384
385    /// Predict tangent coordinates from the supplied already-materialized
386    /// template design. Geometry exp-map dispatch intentionally remains in the
387    /// geometry/FFI layer.
388    pub fn predict_tangent(&self, design: &DesignMatrix) -> Result<Array2<f64>, EstimationError> {
389        self.shared_tangent_fit.predict(design)
390    }
391}
392
393#[derive(Clone, Debug)]
394struct PreparedPenalty {
395    output_slot: usize,
396    column_start: usize,
397    local: Array2<f64>,
398    rank: usize,
399}
400
401#[derive(Clone, Debug)]
402enum SufficientStatistics {
403    Isotropic {
404        gram: Array2<f64>,
405        cross: Array2<f64>,
406    },
407    Fisher {
408        gram: Array2<f64>,
409        cross: Array1<f64>,
410    },
411}
412
413#[derive(Clone, Debug)]
414struct PreparedSharedTangent {
415    design: DesignMatrix,
416    response: Array2<f64>,
417    weights: Array1<f64>,
418    fisher_metric: Option<Array3<f64>>,
419    n_observations: usize,
420    n_coefficients: usize,
421    n_outputs: usize,
422    effective_observations: usize,
423    output_penalty_slots: usize,
424    penalties: Vec<PreparedPenalty>,
425    statistics: SufficientStatistics,
426}
427
428#[derive(Debug)]
429struct Evaluation {
430    cost: f64,
431    gradient: Array1<f64>,
432    hessian: Array2<f64>,
433    coefficients: Array2<f64>,
434    profiled_deviance: f64,
435    penalty_traces: Array1<f64>,
436    lambdas: Array1<f64>,
437    /// `rank(Σ_j λ_j S_j)` in the PER-OUTPUT coefficient basis, carried out of
438    /// the evaluation that measured it. The fit boundary needs it to state the
439    /// joint penalty nullity `mp = p − rank(ΣS)` — the effective dimension no
440    /// amount of smoothing can remove — and re-deriving it there would be a
441    /// second spectral decision about the same matrix.
442    combined_penalty_rank: usize,
443}
444
445#[derive(Debug)]
446struct PenaltySpectrum {
447    rank: usize,
448    log_pseudo_determinant: f64,
449    pseudo_inverse: Array2<f64>,
450}
451
452/// Fit a shared-smoothing multi-output Gaussian model by exact profiled REML.
453pub fn fit_shared_tangent_reml(
454    mut request: SharedTangentRemlRequest,
455) -> Result<SharedTangentRemlFit, EstimationError> {
456    let requested_penalty_count = request.penalties.len();
457    let initial_log_lambdas = request.initial_log_lambdas.take();
458    let prepared = PreparedSharedTangent::from_request(request)?;
459    let n_outer = prepared.penalties.len();
460    if let Some(initial) = initial_log_lambdas.as_ref() {
461        if initial.len() != requested_penalty_count {
462            return Err(invalid(format!(
463                "initial_log_lambdas has length {}, expected {}",
464                initial.len(),
465                requested_penalty_count
466            )));
467        }
468        if initial.iter().any(|value| !value.is_finite()) {
469            return Err(invalid("initial_log_lambdas must be finite"));
470        }
471    }
472    let (rho, outer_iterations, certificate) = if n_outer == 0 {
473        // A parametric model has no smoothing estimand. Its empty analytic
474        // score is exactly stationary and its empty Hessian is PSD by
475        // convention; record that direct certificate instead of routing a
476        // zero-dimensional problem through smoothing-parameter seeding.
477        (
478            Array1::<f64>::zeros(0),
479            0,
480            OuterCriterionCertificate {
481                stationarity:
482                    gam_solve::rho_optimizer::OuterStationarityCertificate::AnalyticGradient {
483                        grad_norm: 0.0,
484                        projected_grad_norm: 0.0,
485                        bound: 0.0,
486                        // A parametric model has no smoothing estimand, so the
487                        // empty score is stationary by construction rather than
488                        // by clearing a band; `bound: 0.0` is a formality and
489                        // borrowing a gradient rung for it would name a
490                        // comparison that never ran (#2530).
491                        rung: gam_problem::StationarityRung::EMPTY_ESTIMAND.into(),
492                    },
493                // A parametric model has no smoothing estimand, so there is
494                // no outer Hessian and never was — `Some(true)` claimed a
495                // measurement that never ran, the second-order twin of the
496                // rung mistake the comment above already avoids (#2561).
497                curvature: gam_solve::rho_optimizer::CurvatureEvidence::NoEstimand,
498                lambdas_railed: Vec::new(),
499                railed_facts: Vec::new(),
500                curvature_floor: None,
501            },
502        )
503    } else {
504        let mut problem = OuterProblem::new(n_outer)
505            .with_gradient(Derivative::Analytic)
506            .with_hessian(DeclaredHessianForm::Dense)
507            .with_disable_fixed_point(true)
508            .with_objective_scale(Some(
509                prepared
510                    .effective_observations
511                    .checked_mul(prepared.n_outputs)
512                    .ok_or_else(|| invalid("effective observation count overflow"))?
513                    as f64,
514            ));
515        if let Some(initial) = initial_log_lambdas.as_ref() {
516            problem = problem.with_initial_rho(Array1::from_iter(
517                prepared
518                    .penalties
519                    .iter()
520                    .map(|penalty| initial[penalty.output_slot]),
521            ));
522        }
523        let mut objective = SharedTangentObjective {
524            prepared: &prepared,
525        };
526        let outer = problem.run(&mut objective, FIT_CONTEXT)?;
527        let certificate = outer
528            .criterion_certificate
529            .clone()
530            .filter(OuterCriterionCertificate::certifies)
531            .ok_or_else(|| EstimationError::RemlDidNotConverge {
532                context: FIT_CONTEXT.to_string(),
533                reason: "outer runner returned without a valid analytic certificate".to_string(),
534                iterations: outer.iterations,
535                final_value: outer.final_value,
536                projected_grad_norm: outer
537                    .criterion_certificate
538                    .as_ref()
539                    .map(|value| value.stationarity.projected_norm()),
540                // The refusal predicate here is "the runner returned no
541                // certificate that certifies" — an existence check, not a
542                // stationarity comparison. Reporting the certificate's own
543                // bound (or `0.0` when there is no certificate at all) beside
544                // the words "against stationarity bound" named a comparison
545                // this route never made (#2458/#2465).
546                stationarity_standard: StationarityStandard::NoComparison,
547                rho_checkpoint: outer.rho.to_vec(),
548            })?;
549        (outer.rho, outer.iterations, certificate)
550    };
551
552    let evaluation = prepared.evaluate(&rho)?;
553    let fitted = predict_from_coefficients(&prepared.design, &evaluation.coefficients)?;
554    // The EDF accounting — which ceiling a per-block trace is admitted against,
555    // what a non-finite trace resolves to, what `edf_by_block` is measured
556    // against, and what floor `edf_total` may not fall below — is the SHARED one
557    // (`gam_solve::estimate::penalized_edf_bundle`, issue #2470). This route was
558    // the last one keeping its own, and it differed on every axis that matters:
559    //
560    // * **Floor.** It clamped `edf_total` to `[0, p]`. The attainable minimum is
561    //   the joint penalty nullity `mp = p − rank(Σ_j λ_j S_j)`: those directions
562    //   are unpenalized, so no amount of smoothing removes them. A `[0, p]`
563    //   clamp lets a noisy trace publish an effective dimension below the
564    //   mathematically possible one, and `σ̂² = D/(rows − edf)` and every SE off
565    //   this path inherit it silently.
566    // * **Saturation.** A redundant block driven to the λ ceiling can overflow
567    //   the raw product `λ_k·tr` to `+∞` on a ridge-stabilized system even
568    //   though the true value is exactly `rank_k` (#1379). The local
569    //   `bounded_roundoff_value` refuses any non-finite value, so this route
570    //   FAILED THE WHOLE FIT on a case the shared accounting resolves to the
571    //   saturated bound.
572    // * **Summation.** `edf_total` is a difference of two like-sized quantities,
573    //   so naive `.sum()` error lands directly in the reported dimension; the
574    //   shared path sums the admitted traces with compensated addition.
575    //
576    // Stating `mp` here is also what lets `collapsed_to_penalty_null_space` see
577    // a fit that kept none of the penalized directions its design offered — a
578    // state this route previously reported as an ordinary converged answer.
579    //
580    // The per-block ceiling is `rank(S_j)·D`: shared smoothing applies each
581    // penalty to the same column block in every one of the `D` outputs, so its
582    // rank in the joint `p = n_coefficients·D` space is `D` copies of the local
583    // rank. That is the ceiling this route already used, and it is the one the
584    // REML criterion prices, so it is carried over unchanged.
585    let block_ranks: Vec<usize> = prepared
586        .penalties
587        .iter()
588        .map(|penalty| penalty.rank * prepared.n_outputs)
589        .collect();
590    // Kept from the accounting this route used to own: a trace that is FINITE
591    // and materially outside `[0, rank]` is not saturation and not roundoff — it
592    // is broken linear algebra upstream, and admitting it at a bound would hide
593    // that. The shared accounting deliberately clamps (a non-finite product is
594    // the ceiling case above), so this input check stays here rather than
595    // becoming a second accounting policy.
596    for (active_index, &rank) in block_ranks.iter().enumerate() {
597        let raw = evaluation.penalty_traces[active_index];
598        if raw.is_finite() {
599            bounded_roundoff_value(raw, 0.0, rank as f64, "per-penalty penalty trace")?;
600        }
601    }
602    let total_coefficients = prepared
603        .n_coefficients
604        .checked_mul(prepared.n_outputs)
605        .ok_or_else(|| invalid("coefficient dimension overflow"))?;
606    let joint_penalty_nullity = prepared
607        .n_coefficients
608        .checked_sub(evaluation.combined_penalty_rank)
609        .ok_or_else(|| invalid("combined penalty rank exceeds coefficient dimension"))?
610        .checked_mul(prepared.n_outputs)
611        .ok_or_else(|| invalid("joint penalty nullity overflow"))?;
612    let bundle = gam_solve::estimate::penalized_edf_bundle(
613        evaluation
614            .penalty_traces
615            .as_slice()
616            .ok_or_else(|| invalid("penalty traces are not contiguous"))?,
617        &block_ranks,
618        total_coefficients,
619        joint_penalty_nullity as f64,
620    );
621    let mut lambdas = Array1::<f64>::zeros(prepared.output_penalty_slots);
622    let mut edf_by_penalty = Array1::<f64>::zeros(prepared.output_penalty_slots);
623    for (active_index, penalty) in prepared.penalties.iter().enumerate() {
624        lambdas[penalty.output_slot] = evaluation.lambdas[active_index];
625        edf_by_penalty[penalty.output_slot] = bundle.edf_by_block[active_index];
626    }
627    let edf_total = bundle.edf_total;
628    let effective_joint_rows = prepared
629        .effective_observations
630        .checked_mul(prepared.n_outputs)
631        .ok_or_else(|| invalid("effective joint row count overflow"))?
632        as f64;
633    let residual_df = effective_joint_rows - edf_total;
634    if !(residual_df.is_finite() && residual_df > 0.0) {
635        return Err(invalid(format!(
636            "residual scale requires positive n*D-edf; got {effective_joint_rows} - {edf_total} = {residual_df}"
637        )));
638    }
639
640    Ok(SharedTangentRemlFit {
641        coefficients: evaluation.coefficients,
642        fitted,
643        sigma2: evaluation.profiled_deviance / residual_df,
644        lambdas,
645        edf_by_penalty,
646        edf_total,
647        reml_score: evaluation.cost,
648        n_observations: prepared.n_observations,
649        n_outputs: prepared.n_outputs,
650        outer_iterations,
651        outer_certificate: certificate,
652    })
653}
654
655struct SharedTangentObjective<'a> {
656    prepared: &'a PreparedSharedTangent,
657}
658
659impl OuterObjective for SharedTangentObjective<'_> {
660    fn capability(&self) -> OuterCapability {
661        OuterCapability {
662            gradient: Derivative::Analytic,
663            hessian: DeclaredHessianForm::Dense,
664            n_params: self.prepared.penalties.len(),
665            psi_dim: 0,
666            fixed_point_available: false,
667            barrier_config: None,
668            prefer_gradient_only: false,
669            disable_fixed_point: true,
670        }
671    }
672
673    fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
674        self.prepared
675            .evaluate(rho)
676            .map(|evaluation| evaluation.cost)
677    }
678
679    fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
680        let evaluation = self.prepared.evaluate(rho)?;
681        Ok(OuterEval {
682            cost: evaluation.cost,
683            gradient: evaluation.gradient,
684            hessian: HessianValue::Dense(evaluation.hessian),
685            inner_beta_hint: None,
686        })
687    }
688
689    fn reset(&mut self) {}
690
691    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
692        // No warm-start slot to fill, but a non-finite seed is a caller error
693        // worth surfacing rather than silently discarding.
694        if beta.iter().any(|value| !value.is_finite()) {
695            return Err(invalid(
696                "seed_inner_state received a non-finite β warm-start vector",
697            ));
698        }
699        Ok(SeedOutcome::NoSlot)
700    }
701}
702
703impl PreparedSharedTangent {
704    fn from_request(request: SharedTangentRemlRequest) -> Result<Self, EstimationError> {
705        let SharedTangentRemlRequest {
706            design,
707            response,
708            weights,
709            fisher_metric,
710            penalties: requested_penalties,
711            initial_log_lambdas: _,
712        } = request;
713        let n = design.nrows();
714        let k = design.ncols();
715        let (response_rows, d) = response.dim();
716        if n == 0 || k == 0 || d == 0 {
717            return Err(invalid(format!(
718                "shared-tangent REML requires non-empty dimensions; got N={n}, K={k}, D={d}"
719            )));
720        }
721        if response_rows != n {
722            return Err(invalid(format!(
723                "response rows {response_rows} do not match design rows {n}"
724            )));
725        }
726        if weights.len() != n {
727            return Err(invalid(format!(
728                "weight length {} does not match design rows {n}",
729                weights.len()
730            )));
731        }
732        if response.iter().any(|value| !value.is_finite()) {
733            return Err(invalid("response must contain only finite values"));
734        }
735        if weights
736            .iter()
737            .any(|value| !value.is_finite() || *value < 0.0)
738        {
739            return Err(invalid("weights must be finite and non-negative"));
740        }
741        let effective_observations = weights.iter().filter(|value| **value > 0.0).count();
742        if effective_observations == 0 {
743            return Err(invalid(
744                "at least one observation must have positive weight",
745            ));
746        }
747        if let Some(metric) = fisher_metric.as_ref()
748            && metric.dim() != (n, d, d)
749        {
750            return Err(invalid(format!(
751                "fisher_metric shape {:?} does not match ({n}, {d}, {d})",
752                metric.dim()
753            )));
754        }
755        let fisher_metric = if let Some(metric) = fisher_metric {
756            let mut validated = Array3::<f64>::zeros(metric.dim());
757            for row in 0..n {
758                let row_metric = validated_metric(metric.slice(s![row, .., ..]).to_owned(), row)?;
759                validated.slice_mut(s![row, .., ..]).assign(&row_metric);
760            }
761            Some(validated)
762        } else {
763            None
764        };
765
766        let penalties = prepare_penalties(&requested_penalties, k)?;
767        let output_penalty_slots = requested_penalties.len();
768        let statistics = match fisher_metric.as_ref() {
769            None => assemble_isotropic_statistics(&design, &response, &weights)?,
770            Some(metric) => assemble_fisher_statistics(&design, &response, &weights, metric)?,
771        };
772
773        Ok(Self {
774            design,
775            response,
776            weights,
777            fisher_metric,
778            n_observations: n,
779            n_coefficients: k,
780            n_outputs: d,
781            effective_observations,
782            output_penalty_slots,
783            penalties,
784            statistics,
785        })
786    }
787
788    fn evaluate(&self, rho: &Array1<f64>) -> Result<Evaluation, EstimationError> {
789        if rho.len() != self.penalties.len() {
790            return Err(invalid(format!(
791                "log-lambda length {} does not match active penalty count {}",
792                rho.len(),
793                self.penalties.len()
794            )));
795        }
796        gam_problem::validate_log_strengths(rho.iter().copied())
797            .map_err(|error| invalid(format!("shared-tangent rho: {error}")))?;
798        match &self.statistics {
799            SufficientStatistics::Isotropic { gram, cross } => {
800                self.evaluate_isotropic(rho, gram, cross)
801            }
802            SufficientStatistics::Fisher { gram, cross } => self.evaluate_fisher(rho, gram, cross),
803        }
804    }
805
806    fn evaluate_isotropic(
807        &self,
808        rho: &Array1<f64>,
809        gram: &Array2<f64>,
810        cross: &Array2<f64>,
811    ) -> Result<Evaluation, EstimationError> {
812        let d = self.n_outputs;
813        let (penalty, lambdas) = self.combined_penalty(rho)?;
814        let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
815        let mut penalized = gram.clone();
816        penalized += &penalty;
817        let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
818        let coefficients = inverse.dot(cross);
819        let profiled_deviance = self.profiled_deviance(&coefficients)?;
820        let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
821        validate_profiled_deviance(profiled_deviance)?;
822
823        let m = self.penalties.len();
824        let mut penalty_traces = Array1::<f64>::zeros(m);
825        let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
826        let mut deviance_first = Array1::<f64>::zeros(m);
827        let mut penalty_beta = Vec::with_capacity(m);
828        for (index, penalty_block) in self.penalties.iter().enumerate() {
829            penalty_traces[index] =
830                d as f64 * trace_local_base(&inverse, penalty_block, lambdas[index]);
831            penalty_logdet_traces[index] = d as f64
832                * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
833            let z = apply_local_base_matrix(penalty_block, lambdas[index], &coefficients);
834            deviance_first[index] = sum_products(&coefficients, &z);
835            penalty_beta.push(z);
836        }
837
838
839        // REML profiles the scale out as `φ̂ = D_p/rdf` with `D_p` the PENALIZED
840        // deviance, so the criterion's data term is `rdf·ln(D_p)` and its
841        // ρ-derivative is `rdf·(β̂ᵗλⱼSⱼβ̂)/D_p` — exactly `deviance_first[j]/D_p`.
842        // That identity is the ENVELOPE theorem, and it holds for `D_p` only:
843        // `D_p` is stationary in β at β̂, the unpenalized `D` is not
844        // (`dD/dρⱼ = 2β̂ᵗS_λA⁻¹λⱼSⱼβ̂`, a different quantity).
845        //
846        // The cost below used the UNPENALIZED `profiled_deviance` while the
847        // gradient and Hessian were already the derivatives of the penalized one,
848        // so the value and its derivatives described two different criteria. That
849        // is why #2597's FD check missed by a large RATIO rather than by an
850        // FD-step artifact: analytic `3.6460865888809835` against central FD
851        // `0.6001259341301612` at `ρ = [−0.2, 0.35]`.
852        //
853        // `Σⱼ deviance_first[j] = β̂ᵗS_λβ̂` exactly, because `S_λ = Σⱼ λⱼSⱼ`, so the
854        // penalized deviance needs no additional quadratic form.
855        //
856        // NOT changed here: the `sigma2 = evaluation.profiled_deviance/residual_df`
857        // at the fit boundary is the same `D` vs `D_p` confusion in the SCALE
858        // estimate, and REML's is `D_p/rdf`. It is left alone deliberately —
859        // correcting it moves every reported standard error on this path, which
860        // wants its own measurement rather than riding on an FD fixture.
861        let penalized_deviance = profiled_deviance + deviance_first.sum();
862        validate_profiled_deviance(penalized_deviance)?;
863
864        let mut gradient = Array1::<f64>::zeros(m);
865        for j in 0..m {
866            gradient[j] = 0.5
867                * (penalty_traces[j] - penalty_logdet_traces[j]
868                    + residual_degrees_of_freedom * deviance_first[j] / penalized_deviance);
869        }
870        let mut hessian = Array2::<f64>::zeros((m, m));
871        for j in 0..m {
872            let h_sandwich = sandwich_local_base(&inverse, &self.penalties[j], lambdas[j]);
873            let p_sandwich =
874                sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
875            for kk in 0..=j {
876                let h_cross = d as f64
877                    * trace_sandwich_local_base(&h_sandwich, &self.penalties[kk], lambdas[kk]);
878                let p_cross = d as f64
879                    * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
880                let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
881                let deviance_cross = sum_products(&penalty_beta[j], &solved_penalty_beta);
882                let delta = usize::from(j == kk) as f64;
883                let logdet_second = delta * penalty_traces[j] - h_cross;
884                let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
885                let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
886                let value = 0.5
887                    * (logdet_second - penalty_logdet_second
888                        + residual_degrees_of_freedom
889                            * (deviance_second / penalized_deviance
890                                - deviance_first[j] * deviance_first[kk]
891                                    / (penalized_deviance * penalized_deviance)));
892                hessian[[j, kk]] = value;
893                hessian[[kk, j]] = value;
894            }
895        }
896        let cost = 0.5
897            * (d as f64 * log_determinant - d as f64 * spectrum.log_pseudo_determinant
898                + residual_degrees_of_freedom
899                    * (1.0
900                        + (2.0 * std::f64::consts::PI * penalized_deviance
901                            / residual_degrees_of_freedom)
902                            .ln()));
903        validate_evaluation(cost, &gradient, &hessian)?;
904        Ok(Evaluation {
905            cost,
906            gradient,
907            hessian,
908            coefficients,
909            profiled_deviance,
910            penalty_traces,
911            lambdas,
912            combined_penalty_rank: spectrum.rank,
913        })
914    }
915
916    fn evaluate_fisher(
917        &self,
918        rho: &Array1<f64>,
919        gram: &Array2<f64>,
920        cross: &Array1<f64>,
921    ) -> Result<Evaluation, EstimationError> {
922        let k = self.n_coefficients;
923        let d = self.n_outputs;
924        let q = k
925            .checked_mul(d)
926            .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
927        let (penalty, lambdas) = self.combined_penalty(rho)?;
928        let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
929        let mut penalized = gram.clone();
930        add_base_penalty_to_joint(&mut penalized, &penalty, d);
931        let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
932        let beta = inverse.dot(cross);
933        let mut coefficients = Array2::<f64>::zeros((k, d));
934        for basis in 0..k {
935            for output in 0..d {
936                coefficients[[basis, output]] = beta[basis * d + output];
937            }
938        }
939        let profiled_deviance = self.profiled_deviance(&coefficients)?;
940        validate_profiled_deviance(profiled_deviance)?;
941        let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
942
943        let m = self.penalties.len();
944        let mut penalty_traces = Array1::<f64>::zeros(m);
945        let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
946        let mut deviance_first = Array1::<f64>::zeros(m);
947        let mut penalty_beta = Vec::with_capacity(m);
948        for (index, penalty_block) in self.penalties.iter().enumerate() {
949            penalty_traces[index] = trace_local_joint(&inverse, penalty_block, lambdas[index], d);
950            penalty_logdet_traces[index] = d as f64
951                * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
952            let z = apply_local_joint_vector(penalty_block, lambdas[index], d, &beta);
953            deviance_first[index] = beta.dot(&z);
954            penalty_beta.push(z);
955        }
956
957
958        // REML profiles the scale out as `φ̂ = D_p/rdf` with `D_p` the PENALIZED
959        // deviance, so the criterion's data term is `rdf·ln(D_p)` and its
960        // ρ-derivative is `rdf·(β̂ᵗλⱼSⱼβ̂)/D_p` — exactly `deviance_first[j]/D_p`.
961        // That identity is the ENVELOPE theorem, and it holds for `D_p` only:
962        // `D_p` is stationary in β at β̂, the unpenalized `D` is not
963        // (`dD/dρⱼ = 2β̂ᵗS_λA⁻¹λⱼSⱼβ̂`, a different quantity).
964        //
965        // The cost below used the UNPENALIZED `profiled_deviance` while the
966        // gradient and Hessian were already the derivatives of the penalized one,
967        // so the value and its derivatives described two different criteria. That
968        // is why #2597's FD check missed by a large RATIO rather than by an
969        // FD-step artifact: analytic `3.6460865888809835` against central FD
970        // `0.6001259341301612` at `ρ = [−0.2, 0.35]`.
971        //
972        // `Σⱼ deviance_first[j] = β̂ᵗS_λβ̂` exactly, because `S_λ = Σⱼ λⱼSⱼ`, so the
973        // penalized deviance needs no additional quadratic form.
974        //
975        // NOT changed here: the `sigma2 = evaluation.profiled_deviance/residual_df`
976        // at the fit boundary is the same `D` vs `D_p` confusion in the SCALE
977        // estimate, and REML's is `D_p/rdf`. It is left alone deliberately —
978        // correcting it moves every reported standard error on this path, which
979        // wants its own measurement rather than riding on an FD fixture.
980        let penalized_deviance = profiled_deviance + deviance_first.sum();
981        validate_profiled_deviance(penalized_deviance)?;
982
983        let mut gradient = Array1::<f64>::zeros(m);
984        for j in 0..m {
985            gradient[j] = 0.5
986                * (penalty_traces[j] - penalty_logdet_traces[j]
987                    + residual_degrees_of_freedom * deviance_first[j] / penalized_deviance);
988        }
989        let mut hessian = Array2::<f64>::zeros((m, m));
990        for j in 0..m {
991            let h_sandwich = sandwich_local_joint(&inverse, &self.penalties[j], lambdas[j], d);
992            let p_sandwich =
993                sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
994            for kk in 0..=j {
995                let h_cross =
996                    trace_sandwich_local_joint(&h_sandwich, &self.penalties[kk], lambdas[kk], d);
997                let p_cross = d as f64
998                    * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
999                let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
1000                let deviance_cross = penalty_beta[j].dot(&solved_penalty_beta);
1001                let delta = usize::from(j == kk) as f64;
1002                let logdet_second = delta * penalty_traces[j] - h_cross;
1003                let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
1004                let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
1005                let value = 0.5
1006                    * (logdet_second - penalty_logdet_second
1007                        + residual_degrees_of_freedom
1008                            * (deviance_second / penalized_deviance
1009                                - deviance_first[j] * deviance_first[kk]
1010                                    / (penalized_deviance * penalized_deviance)));
1011                hessian[[j, kk]] = value;
1012                hessian[[kk, j]] = value;
1013            }
1014        }
1015        let cost = 0.5
1016            * (log_determinant - d as f64 * spectrum.log_pseudo_determinant
1017                + residual_degrees_of_freedom
1018                    * (1.0
1019                        + (2.0 * std::f64::consts::PI * penalized_deviance
1020                            / residual_degrees_of_freedom)
1021                            .ln()));
1022        if inverse.dim() != (q, q) {
1023            return Err(invalid("internal Fisher inverse shape mismatch"));
1024        }
1025        validate_evaluation(cost, &gradient, &hessian)?;
1026        Ok(Evaluation {
1027            cost,
1028            gradient,
1029            hessian,
1030            coefficients,
1031            profiled_deviance,
1032            penalty_traces,
1033            lambdas,
1034            combined_penalty_rank: spectrum.rank,
1035        })
1036    }
1037
1038    /// Evaluate the fitted weighted residual quadratic directly from row
1039    /// chunks.  Forming it as `y'Wy - (X'Wy)' beta` catastrophically cancels
1040    /// on near-interpolating fits; the resulting few ulps are large relative to
1041    /// the residual itself and can move a flat REML optimum by many nats under
1042    /// an otherwise harmless rotation of the tangent frame.
1043    fn profiled_deviance(&self, coefficients: &Array2<f64>) -> Result<f64, EstimationError> {
1044        let n = self.design.nrows();
1045        let k = self.design.ncols();
1046        let d = self.response.ncols();
1047        if coefficients.dim() != (k, d) {
1048            return Err(invalid(format!(
1049                "shared-tangent coefficient shape {:?} does not match ({k}, {d})",
1050                coefficients.dim()
1051            )));
1052        }
1053        let mut quadratic = KahanSum::default();
1054        let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1055        for start in (0..n).step_by(chunk_rows) {
1056            let end = (start + chunk_rows).min(n);
1057            let x_chunk = self
1058                .design
1059                .try_row_chunk(start..end)
1060                .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1061            validate_design_chunk(&x_chunk)?;
1062            let fitted = x_chunk.dot(coefficients);
1063            for local_row in 0..x_chunk.nrows() {
1064                let row = start + local_row;
1065                let weight = self.weights[row];
1066                if weight == 0.0 {
1067                    continue;
1068                }
1069                if let Some(metric) = self.fisher_metric.as_ref() {
1070                    for output_a in 0..d {
1071                        let residual_a =
1072                            self.response[[row, output_a]] - fitted[[local_row, output_a]];
1073                        for output_b in 0..d {
1074                            let residual_b =
1075                                self.response[[row, output_b]] - fitted[[local_row, output_b]];
1076                            quadratic.add(
1077                                weight
1078                                    * residual_a
1079                                    * metric[[row, output_a, output_b]]
1080                                    * residual_b,
1081                            );
1082                        }
1083                    }
1084                } else {
1085                    for output in 0..d {
1086                        let residual = self.response[[row, output]] - fitted[[local_row, output]];
1087                        quadratic.add(weight * residual * residual);
1088                    }
1089                }
1090            }
1091        }
1092        Ok(quadratic.sum())
1093    }
1094
1095    fn combined_penalty(
1096        &self,
1097        rho: &Array1<f64>,
1098    ) -> Result<(Array2<f64>, Array1<f64>), EstimationError> {
1099        let mut combined = Array2::<f64>::zeros((self.n_coefficients, self.n_coefficients));
1100        let lambdas = Array1::from_vec(
1101            gam_problem::checked_exp_log_strengths(rho.iter().copied())
1102                .map_err(|error| invalid(format!("shared-tangent rho: {error}")))?,
1103        );
1104        for (index, penalty) in self.penalties.iter().enumerate() {
1105            let lambda = lambdas[index];
1106            for local_row in 0..penalty.local.nrows() {
1107                for local_col in 0..penalty.local.ncols() {
1108                    combined[[
1109                        penalty.column_start + local_row,
1110                        penalty.column_start + local_col,
1111                    ]] += lambda * penalty.local[[local_row, local_col]];
1112                }
1113            }
1114        }
1115        Ok((combined, lambdas))
1116    }
1117
1118    fn residual_degrees_of_freedom(
1119        &self,
1120        combined_penalty_rank: usize,
1121    ) -> Result<f64, EstimationError> {
1122        let effective_rows = self
1123            .effective_observations
1124            .checked_mul(self.n_outputs)
1125            .ok_or_else(|| invalid("effective joint row count overflow"))?;
1126        let base_nullity = self
1127            .n_coefficients
1128            .checked_sub(combined_penalty_rank)
1129            .ok_or_else(|| invalid("combined penalty rank exceeds coefficient dimension"))?;
1130        let joint_nullity = base_nullity
1131            .checked_mul(self.n_outputs)
1132            .ok_or_else(|| invalid("joint penalty nullity overflow"))?;
1133        if effective_rows <= joint_nullity {
1134            return Err(invalid(format!(
1135                "REML requires more effective joint rows than unpenalized coefficients; got {effective_rows} rows and nullity {joint_nullity}"
1136            )));
1137        }
1138        Ok((effective_rows - joint_nullity) as f64)
1139    }
1140}
1141
1142fn prepare_penalties(
1143    penalties: &[SharedTangentPenalty],
1144    n_coefficients: usize,
1145) -> Result<Vec<PreparedPenalty>, EstimationError> {
1146    let mut prepared = Vec::with_capacity(penalties.len());
1147    for (slot, penalty) in penalties.iter().enumerate() {
1148        let q = penalty.matrix.nrows();
1149        if q != penalty.matrix.ncols() {
1150            return Err(invalid(format!(
1151                "penalty {slot} must be square; got {}x{}",
1152                penalty.matrix.nrows(),
1153                penalty.matrix.ncols()
1154            )));
1155        }
1156        let end = penalty
1157            .column_start
1158            .checked_add(q)
1159            .ok_or_else(|| invalid(format!("penalty {slot} column range overflow")))?;
1160        if end > n_coefficients {
1161            return Err(invalid(format!(
1162                "penalty {slot} column range {}..{end} exceeds design width {n_coefficients}",
1163                penalty.column_start
1164            )));
1165        }
1166        if penalty.matrix.iter().any(|value| !value.is_finite()) {
1167            return Err(invalid(format!(
1168                "penalty {slot} contains non-finite values"
1169            )));
1170        }
1171        let local = symmetric_average(&penalty.matrix);
1172        let spectrum = penalty_spectrum(&local, &format!("shared-tangent penalty {slot}"))?;
1173        if spectrum.rank == 0 {
1174            continue;
1175        }
1176        prepared.push(PreparedPenalty {
1177            output_slot: slot,
1178            column_start: penalty.column_start,
1179            local,
1180            rank: spectrum.rank,
1181        });
1182    }
1183    Ok(prepared)
1184}
1185
1186fn assemble_isotropic_statistics(
1187    design: &DesignMatrix,
1188    response: &Array2<f64>,
1189    weights: &Array1<f64>,
1190) -> Result<SufficientStatistics, EstimationError> {
1191    let n = design.nrows();
1192    let k = design.ncols();
1193    let d = response.ncols();
1194    let mut gram = Array2::<f64>::zeros((k, k));
1195    let mut cross = Array2::<f64>::zeros((k, d));
1196    let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1197    for start in (0..n).step_by(chunk_rows) {
1198        let end = (start + chunk_rows).min(n);
1199        let x_chunk = design
1200            .try_row_chunk(start..end)
1201            .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1202        validate_design_chunk(&x_chunk)?;
1203        let weight_chunk = weights.slice(s![start..end]);
1204        let response_chunk = response.slice(s![start..end, ..]);
1205        gram += &fast_xt_diag_x(&x_chunk, &weight_chunk);
1206        cross += &fast_xt_diag_y(&x_chunk, &weight_chunk, &response_chunk);
1207    }
1208    Ok(SufficientStatistics::Isotropic { gram, cross })
1209}
1210
1211fn assemble_fisher_statistics(
1212    design: &DesignMatrix,
1213    response: &Array2<f64>,
1214    weights: &Array1<f64>,
1215    fisher_metric: &Array3<f64>,
1216) -> Result<SufficientStatistics, EstimationError> {
1217    let n = design.nrows();
1218    let k = design.ncols();
1219    let d = response.ncols();
1220    let q = k
1221        .checked_mul(d)
1222        .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
1223    let mut gram = Array2::<f64>::zeros((q, q));
1224    let mut cross = Array1::<f64>::zeros(q);
1225    let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1226    for start in (0..n).step_by(chunk_rows) {
1227        let end = (start + chunk_rows).min(n);
1228        let x_chunk = design
1229            .try_row_chunk(start..end)
1230            .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1231        validate_design_chunk(&x_chunk)?;
1232        for local_row in 0..x_chunk.nrows() {
1233            let row = start + local_row;
1234            let metric = fisher_metric.slice(s![row, .., ..]);
1235            let y = response.row(row);
1236            let metric_y = metric.dot(&y);
1237            let weight = weights[row];
1238            for basis_a in 0..k {
1239                let x_a = x_chunk[[local_row, basis_a]];
1240                for output in 0..d {
1241                    cross[basis_a * d + output] += weight * x_a * metric_y[output];
1242                }
1243                for basis_b in 0..k {
1244                    let scale = weight * x_a * x_chunk[[local_row, basis_b]];
1245                    if scale == 0.0 {
1246                        continue;
1247                    }
1248                    for output_a in 0..d {
1249                        for output_b in 0..d {
1250                            gram[[basis_a * d + output_a, basis_b * d + output_b]] +=
1251                                scale * metric[[output_a, output_b]];
1252                        }
1253                    }
1254                }
1255            }
1256        }
1257    }
1258    Ok(SufficientStatistics::Fisher { gram, cross })
1259}
1260
1261fn validated_metric(mut metric: Array2<f64>, row: usize) -> Result<Array2<f64>, EstimationError> {
1262    if metric.iter().any(|value| !value.is_finite()) {
1263        return Err(invalid(format!(
1264            "fisher_metric row {row} contains non-finite values"
1265        )));
1266    }
1267    let scale = metric
1268        .iter()
1269        .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1270    let tolerance = f64::EPSILON.sqrt() * metric.nrows().max(1) as f64 * scale;
1271    for a in 0..metric.nrows() {
1272        for b in (a + 1)..metric.ncols() {
1273            if (metric[[a, b]] - metric[[b, a]]).abs() > tolerance {
1274                return Err(invalid(format!(
1275                    "fisher_metric row {row} is not symmetric at ({a}, {b})"
1276                )));
1277            }
1278            let average = 0.5 * (metric[[a, b]] + metric[[b, a]]);
1279            metric[[a, b]] = average;
1280            metric[[b, a]] = average;
1281        }
1282    }
1283    metric.cholesky(Side::Lower).map_err(|error| {
1284        invalid(format!(
1285            "fisher_metric row {row} must be positive definite: {error}"
1286        ))
1287    })?;
1288    Ok(metric)
1289}
1290
1291fn penalty_spectrum(
1292    penalty: &Array2<f64>,
1293    context: &str,
1294) -> Result<PenaltySpectrum, EstimationError> {
1295    let (eigenvalues, eigenvectors) = penalty
1296        .eigh(Side::Lower)
1297        .map_err(EstimationError::EigendecompositionFailed)?;
1298    let scale = eigenvalues
1299        .iter()
1300        .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1301    let tolerance = f64::EPSILON.sqrt() * eigenvalues.len().max(1) as f64 * scale;
1302    let mut rank = 0usize;
1303    let mut log_pseudo_determinant = 0.0;
1304    let mut pseudo_inverse = Array2::<f64>::zeros(penalty.dim());
1305    for (index, &value) in eigenvalues.iter().enumerate() {
1306        if !value.is_finite() {
1307            return Err(EstimationError::PenaltySpectrumNonFinite {
1308                context: context.to_string(),
1309                index,
1310                value,
1311            });
1312        }
1313        if value < -tolerance {
1314            return Err(EstimationError::PenaltySpectrumIndefinite {
1315                context: context.to_string(),
1316                index,
1317                value,
1318                tolerance,
1319                scale,
1320            });
1321        }
1322        if value <= tolerance {
1323            continue;
1324        }
1325        rank += 1;
1326        log_pseudo_determinant += value.ln();
1327        for row in 0..penalty.nrows() {
1328            for col in 0..penalty.ncols() {
1329                pseudo_inverse[[row, col]] +=
1330                    eigenvectors[[row, index]] * eigenvectors[[col, index]] / value;
1331            }
1332        }
1333    }
1334    Ok(PenaltySpectrum {
1335        rank,
1336        log_pseudo_determinant,
1337        pseudo_inverse,
1338    })
1339}
1340
1341fn spd_inverse_and_logdet(matrix: &Array2<f64>) -> Result<(Array2<f64>, f64), EstimationError> {
1342    let factor =
1343        matrix
1344            .cholesky(Side::Lower)
1345            .map_err(|_| EstimationError::ModelIsIllConditioned {
1346                condition_number: f64::INFINITY,
1347            })?;
1348    let diagonal = factor.diag();
1349    if diagonal
1350        .iter()
1351        .any(|value| !value.is_finite() || *value <= 0.0)
1352    {
1353        return Err(EstimationError::ModelIsIllConditioned {
1354            condition_number: f64::INFINITY,
1355        });
1356    }
1357    let log_determinant = 2.0 * diagonal.iter().map(|value| value.ln()).sum::<f64>();
1358    let identity = Array2::<f64>::eye(matrix.nrows());
1359    let inverse = factor.solve_mat(&identity);
1360    if !log_determinant.is_finite() || inverse.iter().any(|value| !value.is_finite()) {
1361        return Err(EstimationError::ModelIsIllConditioned {
1362            condition_number: f64::INFINITY,
1363        });
1364    }
1365    Ok((inverse, log_determinant))
1366}
1367
1368fn trace_local_base(inverse: &Array2<f64>, penalty: &PreparedPenalty, lambda: f64) -> f64 {
1369    let mut trace = 0.0;
1370    for row in 0..penalty.local.nrows() {
1371        for col in 0..penalty.local.ncols() {
1372            trace += lambda
1373                * penalty.local[[row, col]]
1374                * inverse[[penalty.column_start + col, penalty.column_start + row]];
1375        }
1376    }
1377    trace
1378}
1379
1380fn trace_local_joint(
1381    inverse: &Array2<f64>,
1382    penalty: &PreparedPenalty,
1383    lambda: f64,
1384    n_outputs: usize,
1385) -> f64 {
1386    let mut trace = 0.0;
1387    for output in 0..n_outputs {
1388        for row in 0..penalty.local.nrows() {
1389            for col in 0..penalty.local.ncols() {
1390                trace += lambda
1391                    * penalty.local[[row, col]]
1392                    * inverse[[
1393                        (penalty.column_start + col) * n_outputs + output,
1394                        (penalty.column_start + row) * n_outputs + output,
1395                    ]];
1396            }
1397        }
1398    }
1399    trace
1400}
1401
1402fn apply_local_base_matrix(
1403    penalty: &PreparedPenalty,
1404    lambda: f64,
1405    matrix: &Array2<f64>,
1406) -> Array2<f64> {
1407    let mut output = Array2::<f64>::zeros(matrix.dim());
1408    for row in 0..penalty.local.nrows() {
1409        for col in 0..penalty.local.ncols() {
1410            let value = lambda * penalty.local[[row, col]];
1411            for output_index in 0..matrix.ncols() {
1412                output[[penalty.column_start + row, output_index]] +=
1413                    value * matrix[[penalty.column_start + col, output_index]];
1414            }
1415        }
1416    }
1417    output
1418}
1419
1420fn apply_local_joint_vector(
1421    penalty: &PreparedPenalty,
1422    lambda: f64,
1423    n_outputs: usize,
1424    vector: &Array1<f64>,
1425) -> Array1<f64> {
1426    let mut output = Array1::<f64>::zeros(vector.len());
1427    for output_index in 0..n_outputs {
1428        for row in 0..penalty.local.nrows() {
1429            for col in 0..penalty.local.ncols() {
1430                output[(penalty.column_start + row) * n_outputs + output_index] += lambda
1431                    * penalty.local[[row, col]]
1432                    * vector[(penalty.column_start + col) * n_outputs + output_index];
1433            }
1434        }
1435    }
1436    output
1437}
1438
1439fn sandwich_local_base(
1440    inverse: &Array2<f64>,
1441    penalty: &PreparedPenalty,
1442    lambda: f64,
1443) -> Array2<f64> {
1444    let dimension = inverse.nrows();
1445    let mut result = Array2::<f64>::zeros((dimension, dimension));
1446    for local_row in 0..penalty.local.nrows() {
1447        let global_row = penalty.column_start + local_row;
1448        for local_col in 0..penalty.local.ncols() {
1449            let value = lambda * penalty.local[[local_row, local_col]];
1450            if value == 0.0 {
1451                continue;
1452            }
1453            let global_col = penalty.column_start + local_col;
1454            for row in 0..dimension {
1455                let left = inverse[[row, global_row]] * value;
1456                for col in 0..dimension {
1457                    result[[row, col]] += left * inverse[[global_col, col]];
1458                }
1459            }
1460        }
1461    }
1462    result
1463}
1464
1465fn sandwich_local_joint(
1466    inverse: &Array2<f64>,
1467    penalty: &PreparedPenalty,
1468    lambda: f64,
1469    n_outputs: usize,
1470) -> Array2<f64> {
1471    let dimension = inverse.nrows();
1472    let mut result = Array2::<f64>::zeros((dimension, dimension));
1473    for output in 0..n_outputs {
1474        for local_row in 0..penalty.local.nrows() {
1475            let global_row = (penalty.column_start + local_row) * n_outputs + output;
1476            for local_col in 0..penalty.local.ncols() {
1477                let value = lambda * penalty.local[[local_row, local_col]];
1478                if value == 0.0 {
1479                    continue;
1480                }
1481                let global_col = (penalty.column_start + local_col) * n_outputs + output;
1482                for row in 0..dimension {
1483                    let left = inverse[[row, global_row]] * value;
1484                    for col in 0..dimension {
1485                        result[[row, col]] += left * inverse[[global_col, col]];
1486                    }
1487                }
1488            }
1489        }
1490    }
1491    result
1492}
1493
1494fn trace_sandwich_local_base(
1495    sandwich: &Array2<f64>,
1496    penalty: &PreparedPenalty,
1497    lambda: f64,
1498) -> f64 {
1499    let mut trace = 0.0;
1500    for row in 0..penalty.local.nrows() {
1501        for col in 0..penalty.local.ncols() {
1502            trace += lambda
1503                * penalty.local[[row, col]]
1504                * sandwich[[penalty.column_start + col, penalty.column_start + row]];
1505        }
1506    }
1507    trace
1508}
1509
1510fn trace_sandwich_local_joint(
1511    sandwich: &Array2<f64>,
1512    penalty: &PreparedPenalty,
1513    lambda: f64,
1514    n_outputs: usize,
1515) -> f64 {
1516    let mut trace = 0.0;
1517    for output in 0..n_outputs {
1518        for row in 0..penalty.local.nrows() {
1519            for col in 0..penalty.local.ncols() {
1520                trace += lambda
1521                    * penalty.local[[row, col]]
1522                    * sandwich[[
1523                        (penalty.column_start + col) * n_outputs + output,
1524                        (penalty.column_start + row) * n_outputs + output,
1525                    ]];
1526            }
1527        }
1528    }
1529    trace
1530}
1531
1532fn add_base_penalty_to_joint(joint: &mut Array2<f64>, penalty: &Array2<f64>, n_outputs: usize) {
1533    for row in 0..penalty.nrows() {
1534        for col in 0..penalty.ncols() {
1535            let value = penalty[[row, col]];
1536            for output in 0..n_outputs {
1537                joint[[row * n_outputs + output, col * n_outputs + output]] += value;
1538            }
1539        }
1540    }
1541}
1542
1543fn symmetric_average(matrix: &Array2<f64>) -> Array2<f64> {
1544    let mut output = matrix.clone();
1545    for row in 0..matrix.nrows() {
1546        for col in (row + 1)..matrix.ncols() {
1547            let average = 0.5 * (matrix[[row, col]] + matrix[[col, row]]);
1548            output[[row, col]] = average;
1549            output[[col, row]] = average;
1550        }
1551    }
1552    output
1553}
1554
1555fn predict_from_coefficients(
1556    design: &DesignMatrix,
1557    coefficients: &Array2<f64>,
1558) -> Result<Array2<f64>, EstimationError> {
1559    if design.ncols() != coefficients.nrows() {
1560        return Err(invalid(format!(
1561            "prediction design width {} does not match coefficient rows {}",
1562            design.ncols(),
1563            coefficients.nrows()
1564        )));
1565    }
1566    let mut prediction = Array2::<f64>::zeros((design.nrows(), coefficients.ncols()));
1567    for output in 0..coefficients.ncols() {
1568        let values = design.apply(&coefficients.column(output).to_owned());
1569        prediction.column_mut(output).assign(&values);
1570    }
1571    if prediction.iter().any(|value| !value.is_finite()) {
1572        return Err(invalid("prediction produced non-finite values"));
1573    }
1574    Ok(prediction)
1575}
1576
1577fn sum_products(left: &Array2<f64>, right: &Array2<f64>) -> f64 {
1578    left.iter()
1579        .zip(right.iter())
1580        .map(|(left, right)| left * right)
1581        .sum()
1582}
1583
1584fn validate_design_chunk(chunk: &Array2<f64>) -> Result<(), EstimationError> {
1585    if chunk.iter().any(|value| !value.is_finite()) {
1586        return Err(invalid("design contains non-finite values"));
1587    }
1588    Ok(())
1589}
1590
1591fn validate_profiled_deviance(value: f64) -> Result<(), EstimationError> {
1592    if !value.is_finite() || value <= 0.0 {
1593        return Err(EstimationError::RemlOptimizationFailed(format!(
1594            "{FIT_CONTEXT}: profiled penalized deviance must be finite and positive, got {value}"
1595        )));
1596    }
1597    Ok(())
1598}
1599
1600fn validate_evaluation(
1601    cost: f64,
1602    gradient: &Array1<f64>,
1603    hessian: &Array2<f64>,
1604) -> Result<(), EstimationError> {
1605    if !cost.is_finite()
1606        || gradient.iter().any(|value| !value.is_finite())
1607        || hessian.iter().any(|value| !value.is_finite())
1608    {
1609        return Err(EstimationError::RemlOptimizationFailed(format!(
1610            "{FIT_CONTEXT}: objective evaluation produced non-finite value or derivatives"
1611        )));
1612    }
1613    Ok(())
1614}
1615
1616fn validate_archived_tangent_fit(
1617    fit: &SharedTangentRemlFit,
1618) -> Result<(), ResponseGeometryModelError> {
1619    if fit.n_observations == 0
1620        || fit.n_outputs == 0
1621        || fit.coefficients.nrows() == 0
1622        || fit.coefficients.ncols() != fit.n_outputs
1623        || fit.fitted.dim() != (fit.n_observations, fit.n_outputs)
1624    {
1625        return Err(ResponseGeometryModelError::InvalidMetadata(
1626            "shared tangent fit has inconsistent dimensions".to_string(),
1627        ));
1628    }
1629    if fit.lambdas.len() != fit.edf_by_penalty.len() {
1630        return Err(ResponseGeometryModelError::InvalidMetadata(
1631            "shared tangent lambda and EDF vectors are misaligned".to_string(),
1632        ));
1633    }
1634    if fit.coefficients.iter().any(|value| !value.is_finite())
1635        || fit.fitted.iter().any(|value| !value.is_finite())
1636        || fit
1637            .lambdas
1638            .iter()
1639            .any(|value| !value.is_finite() || *value < 0.0)
1640        || fit
1641            .edf_by_penalty
1642            .iter()
1643            .any(|value| !value.is_finite() || *value < 0.0)
1644        || !fit.sigma2.is_finite()
1645        || fit.sigma2 <= 0.0
1646        || !fit.edf_total.is_finite()
1647        || fit.edf_total < 0.0
1648        || !fit.reml_score.is_finite()
1649    {
1650        return Err(ResponseGeometryModelError::InvalidMetadata(
1651            "shared tangent fit contains invalid numerical values".to_string(),
1652        ));
1653    }
1654    if !fit.outer_certificate.certifies() {
1655        return Err(ResponseGeometryModelError::InvalidMetadata(
1656            "shared tangent fit lacks a valid convergence certificate".to_string(),
1657        ));
1658    }
1659    Ok(())
1660}
1661
1662fn bounded_roundoff_value(
1663    value: f64,
1664    lower: f64,
1665    upper: f64,
1666    context: &str,
1667) -> Result<f64, EstimationError> {
1668    let tolerance = f64::EPSILON.sqrt() * upper.abs().max(1.0);
1669    if !value.is_finite() || value < lower - tolerance || value > upper + tolerance {
1670        return Err(EstimationError::RemlOptimizationFailed(format!(
1671            "{FIT_CONTEXT}: {context} {value} lies outside [{lower}, {upper}] beyond roundoff"
1672        )));
1673    }
1674    Ok(value.clamp(lower, upper))
1675}
1676
1677fn invalid(message: impl Into<String>) -> EstimationError {
1678    EstimationError::InvalidInput(message.into())
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683    use super::*;
1684    use gam_linalg::test_support::no_densify_design;
1685    use ndarray::{Array3, array};
1686
1687    fn fixture_request(fisher_metric: Option<Array3<f64>>) -> SharedTangentRemlRequest {
1688        let design = array![
1689            [1.0, -1.0, 0.5],
1690            [1.0, -0.5, -0.2],
1691            [1.0, 0.0, 0.3],
1692            [1.0, 0.5, 0.8],
1693            [1.0, 1.0, -0.4],
1694            [1.0, 1.5, 0.1]
1695        ];
1696        let response = array![
1697            [-0.7, 0.4],
1698            [-0.1, 0.1],
1699            [0.2, -0.3],
1700            [0.8, -0.2],
1701            [1.1, 0.5],
1702            [1.7, 0.2]
1703        ];
1704        let penalties = vec![
1705            SharedTangentPenalty::new(1, array![[1.0, 0.0], [0.0, 0.0]]),
1706            SharedTangentPenalty::new(1, array![[0.0, 0.0], [0.0, 1.0]]),
1707        ];
1708        SharedTangentRemlRequest::new(
1709            no_densify_design(design),
1710            response,
1711            array![1.0, 0.8, 1.2, 1.0, 0.9, 1.1],
1712            fisher_metric,
1713            penalties,
1714        )
1715    }
1716
1717    #[test]
1718    fn operator_backed_isotropic_path_matches_streamed_identity_fisher_path() {
1719        let isotropic_request = fixture_request(None);
1720        let n = isotropic_request.response.nrows();
1721        let d = isotropic_request.response.ncols();
1722        let mut identity_metric = Array3::<f64>::zeros((n, d, d));
1723        for row in 0..n {
1724            for output in 0..d {
1725                identity_metric[[row, output, output]] = 1.0;
1726            }
1727        }
1728        let fisher_request = fixture_request(Some(identity_metric));
1729        let isotropic = PreparedSharedTangent::from_request(isotropic_request)
1730            .expect("prepare isotropic without densifying");
1731        let fisher = PreparedSharedTangent::from_request(fisher_request)
1732            .expect("prepare Fisher without densifying");
1733        let rho = array![-0.4, 0.7];
1734        let left = isotropic.evaluate(&rho).expect("isotropic eval");
1735        let right = fisher.evaluate(&rho).expect("Fisher eval");
1736        assert_close(left.cost, right.cost, 2.0e-11);
1737        assert_array1_close(&left.gradient, &right.gradient, 2.0e-10);
1738        assert_array2_close(&left.hessian, &right.hessian, 2.0e-9);
1739        assert_array2_close(&left.coefficients, &right.coefficients, 2.0e-11);
1740    }
1741
1742    #[test]
1743    fn analytic_gradient_and_hessian_match_test_only_finite_differences() {
1744        let request = fixture_request(None);
1745        let prepared = PreparedSharedTangent::from_request(request).expect("prepare");
1746        let rho = array![-0.2, 0.35];
1747        let exact = prepared.evaluate(&rho).expect("exact eval");
1748        let step = f64::EPSILON.cbrt();
1749        for j in 0..rho.len() {
1750            let mut plus = rho.clone();
1751            let mut minus = rho.clone();
1752            plus[j] += step;
1753            minus[j] -= step;
1754            let plus_eval = prepared.evaluate(&plus).expect("plus eval");
1755            let minus_eval = prepared.evaluate(&minus).expect("minus eval");
1756            let gradient_fd = (plus_eval.cost - minus_eval.cost) / (2.0 * step);
1757            assert_close(exact.gradient[j], gradient_fd, 2.0e-6);
1758            for k in 0..rho.len() {
1759                let hessian_fd = (plus_eval.gradient[k] - minus_eval.gradient[k]) / (2.0 * step);
1760                assert_close(exact.hessian[[k, j]], hessian_fd, 3.0e-6);
1761            }
1762        }
1763    }
1764
1765    /// #2629 scope item 2 — settle the `shared-tangent` row of the objective
1766    /// table by MEASUREMENT rather than by grep.
1767    ///
1768    /// #2545 taught the outer certificate to subtract the soft ρ-guard barrier
1769    /// from its view at railed coordinates, and reached only the objectives that
1770    /// PUBLISH it via `OuterObjective::soft_rho_guard_gradient`. The trait's
1771    /// `None` default means "this objective carries no barrier", and it is
1772    /// byte-identical, at the seam, to an objective that carries one and says
1773    /// nothing — which is the whole of #2545/#2629.
1774    ///
1775    /// [`SharedTangentObjective`] takes the default. #2629's grounds for that
1776    /// being correct were a call-graph argument: `RemlState::build_prior` is the
1777    /// only site that adds the barrier to a criterion, and this objective's
1778    /// `eval` goes to `PreparedSharedTangent::evaluate` — its own criterion,
1779    /// holding no `RemlState`. This is the number instead.
1780    ///
1781    /// Read the ladder printed below rather than just the verdict: a criterion
1782    /// carrying the barrier is pinned within a hair of `w·a·tanh(a·ρ)` at every
1783    /// rung, ~1.33e-7, and cannot decay past it.
1784    #[test]
1785    fn the_shared_tangent_criterion_carries_no_soft_rho_guard_floor_2629() {
1786        use gam_solve::rho_optimizer::soft_rho_guard_floor::{
1787            GuardLadderRung, SATURATED_RHO_LADDER, classify_soft_rho_guard_floor,
1788            soft_rho_guard_emission_at,
1789        };
1790
1791        let prepared =
1792            PreparedSharedTangent::from_request(fixture_request(None)).expect("prepare");
1793        // Hold BOTH penalties at the rung: the barrier is added to every ρ
1794        // coordinate identically by `build_prior`, so a floor would show on
1795        // either, and railing both is the configuration a λ=∞ face actually is.
1796        let ladders: Vec<Vec<GuardLadderRung>> = (0..2)
1797            .map(|coord| {
1798                SATURATED_RHO_LADDER
1799                    .iter()
1800                    .map(|&probe| {
1801                        let rho = Array1::from_elem(2, probe);
1802                        let evaluation = prepared
1803                            .evaluate(&rho)
1804                            .unwrap_or_else(|e| panic!("shared-tangent eval at rho={probe}: {e}"));
1805                        GuardLadderRung {
1806                            rho: probe,
1807                            rho_gradient: evaluation.gradient[coord],
1808                        }
1809                    })
1810                    .collect()
1811            })
1812            .collect();
1813
1814        for (coord, ladder) in ladders.iter().enumerate() {
1815            // This criterion holds no weighted `RemlState`, so there is no
1816            // weight anchor to speak of and none to pass.
1817            let verdict = classify_soft_rho_guard_floor(ladder, 0.0);
1818            let rendered = ladder
1819                .iter()
1820                .map(|rung| {
1821                    format!(
1822                        "(rho={:.0}, g={:+.6e}, guard={:.6e})",
1823                        rung.rho,
1824                        rung.rho_gradient,
1825                        soft_rho_guard_emission_at(rung.rho, 0.0)
1826                    )
1827                })
1828                .collect::<Vec<_>>()
1829                .join(" ");
1830            eprintln!(
1831                "[#2629-table] shared-tangent k={coord}: {} | {rendered}",
1832                verdict.summary()
1833            );
1834            assert!(
1835                verdict.is_absent(),
1836                "#2629's table lists `shared-tangent` as carrying no soft rho-guard \
1837                 barrier. If that is wrong, every railed coordinate of every \
1838                 shared-tangent fit carries a standing |Pg| >= w*a = 1.3333e-7 that \
1839                 no convergence clears, and this objective owes the seam a \
1840                 publication. k={coord}: {} | ladder {rendered}",
1841                verdict.summary()
1842            );
1843
1844            // The control that stops "absent" from being vacuous: inject the
1845            // barrier into this very ladder and require the verdict off absent.
1846            // A floor cannot be shown missing by a measurement that could not
1847            // have shown it present.
1848            let injected: Vec<GuardLadderRung> = ladder
1849                .iter()
1850                .map(|rung| GuardLadderRung {
1851                    rho: rung.rho,
1852                    rho_gradient: rung.rho_gradient
1853                        + soft_rho_guard_emission_at(rung.rho, 0.0),
1854                })
1855                .collect();
1856            let injected_verdict = classify_soft_rho_guard_floor(&injected, 0.0);
1857            assert!(
1858                !injected_verdict.is_absent(),
1859                "k={coord}: with the barrier explicitly added this ladder must NOT \
1860                 read as absent — if it does, the fixture is blind to the very \
1861                 thing the assertion above claims to have looked for. Got: {}",
1862                injected_verdict.summary()
1863            );
1864            eprintln!(
1865                "[#2629-control] shared-tangent k={coord} + injected barrier: {}",
1866                injected_verdict.summary()
1867            );
1868        }
1869    }
1870
1871    #[test]
1872    fn streamed_varying_fisher_statistics_match_explicit_joint_oracle() {
1873        let base = fixture_request(None);
1874        let n = base.response.nrows();
1875        let d = base.response.ncols();
1876        let mut metric = Array3::<f64>::zeros((n, d, d));
1877        for row in 0..n {
1878            let off = 0.04 * (row as f64 + 1.0);
1879            metric[[row, 0, 0]] = 1.2 + 0.1 * row as f64;
1880            metric[[row, 0, 1]] = off;
1881            metric[[row, 1, 0]] = off;
1882            metric[[row, 1, 1]] = 0.9 + 0.05 * row as f64;
1883        }
1884        let request = fixture_request(Some(metric.clone()));
1885        let prepared =
1886            PreparedSharedTangent::from_request(request.clone()).expect("prepare Fisher");
1887        let SufficientStatistics::Fisher { gram, cross } = &prepared.statistics else {
1888            panic!("expected Fisher statistics")
1889        };
1890        let x = base.design.try_row_chunk(0..n).expect("test design rows");
1891        let k = x.ncols();
1892        let q = k * d;
1893        let mut oracle_gram = Array2::<f64>::zeros((q, q));
1894        let mut oracle_cross = Array1::<f64>::zeros(q);
1895        let mut oracle_response = 0.0;
1896        for row in 0..n {
1897            for a in 0..k {
1898                for o in 0..d {
1899                    let ao = a * d + o;
1900                    for p in 0..d {
1901                        oracle_cross[ao] += request.weights[row]
1902                            * x[[row, a]]
1903                            * metric[[row, o, p]]
1904                            * request.response[[row, p]];
1905                    }
1906                    for b in 0..k {
1907                        for p in 0..d {
1908                            oracle_gram[[ao, b * d + p]] += request.weights[row]
1909                                * x[[row, a]]
1910                                * x[[row, b]]
1911                                * metric[[row, o, p]];
1912                        }
1913                    }
1914                }
1915            }
1916            let y = request.response.row(row);
1917            oracle_response += request.weights[row] * y.dot(&metric.slice(s![row, .., ..]).dot(&y));
1918        }
1919        assert_array2_close(gram, &oracle_gram, 2.0e-12);
1920        assert_array1_close(cross, &oracle_cross, 2.0e-12);
1921        let zero_coefficients = Array2::<f64>::zeros((k, d));
1922        let direct_response_quadratic = prepared
1923            .profiled_deviance(&zero_coefficients)
1924            .expect("direct zero-fit quadratic");
1925        assert_close(direct_response_quadratic, oracle_response, 2.0e-12);
1926    }
1927
1928    #[test]
1929    fn parametric_fit_is_certified_serializable_and_predicts_in_core() {
1930        let design = array![[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
1931        let response = array![[0.2, -0.1], [0.9, 0.4], [2.1, 0.8], [2.8, 1.4]];
1932        let request = SharedTangentRemlRequest::from_dense(
1933            design.clone(),
1934            response,
1935            Array1::ones(4),
1936            None,
1937            Vec::new(),
1938        );
1939        let fit = fit_shared_tangent_reml(request).expect("certified parametric fit");
1940        assert!(fit.outer_certificate.certifies());
1941        let prediction = fit.predict_dense(design).expect("core prediction");
1942        assert_array2_close(&prediction, &fit.fitted, 1.0e-12);
1943        let encoded = serde_json::to_string(&fit).expect("serialize fit");
1944        let decoded: SharedTangentRemlFit =
1945            serde_json::from_str(&encoded).expect("deserialize fit");
1946        assert_array2_close(&decoded.coefficients, &fit.coefficients, 0.0);
1947        assert!(decoded.outer_certificate.certifies());
1948    }
1949
1950    fn assert_close(left: f64, right: f64, tolerance: f64) {
1951        let scale = left.abs().max(right.abs()).max(1.0);
1952        assert!(
1953            (left - right).abs() <= tolerance * scale,
1954            "{left} != {right} within relative tolerance {tolerance}"
1955        );
1956    }
1957
1958    fn assert_array1_close(left: &Array1<f64>, right: &Array1<f64>, tolerance: f64) {
1959        assert_eq!(left.len(), right.len());
1960        for (left, right) in left.iter().zip(right.iter()) {
1961            assert_close(*left, *right, tolerance);
1962        }
1963    }
1964
1965    fn assert_array2_close(left: &Array2<f64>, right: &Array2<f64>, tolerance: f64) {
1966        assert_eq!(left.dim(), right.dim());
1967        for (left, right) in left.iter().zip(right.iter()) {
1968            assert_close(*left, *right, tolerance);
1969        }
1970    }
1971}