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};
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}
438
439#[derive(Debug)]
440struct PenaltySpectrum {
441    rank: usize,
442    log_pseudo_determinant: f64,
443    pseudo_inverse: Array2<f64>,
444}
445
446/// Fit a shared-smoothing multi-output Gaussian model by exact profiled REML.
447pub fn fit_shared_tangent_reml(
448    mut request: SharedTangentRemlRequest,
449) -> Result<SharedTangentRemlFit, EstimationError> {
450    let requested_penalty_count = request.penalties.len();
451    let initial_log_lambdas = request.initial_log_lambdas.take();
452    let prepared = PreparedSharedTangent::from_request(request)?;
453    let n_outer = prepared.penalties.len();
454    if let Some(initial) = initial_log_lambdas.as_ref() {
455        if initial.len() != requested_penalty_count {
456            return Err(invalid(format!(
457                "initial_log_lambdas has length {}, expected {}",
458                initial.len(),
459                requested_penalty_count
460            )));
461        }
462        if initial.iter().any(|value| !value.is_finite()) {
463            return Err(invalid("initial_log_lambdas must be finite"));
464        }
465    }
466    let (rho, outer_iterations, certificate) = if n_outer == 0 {
467        // A parametric model has no smoothing estimand. Its empty analytic
468        // score is exactly stationary and its empty Hessian is PSD by
469        // convention; record that direct certificate instead of routing a
470        // zero-dimensional problem through smoothing-parameter seeding.
471        (
472            Array1::<f64>::zeros(0),
473            0,
474            OuterCriterionCertificate {
475                stationarity:
476                    gam_solve::rho_optimizer::OuterStationarityCertificate::AnalyticGradient {
477                        grad_norm: 0.0,
478                        projected_grad_norm: 0.0,
479                        bound: 0.0,
480                    },
481                hessian_psd: Some(true),
482                lambdas_railed: Vec::new(),
483            },
484        )
485    } else {
486        let mut problem = OuterProblem::new(n_outer)
487            .with_gradient(Derivative::Analytic)
488            .with_hessian(DeclaredHessianForm::Dense)
489            .with_disable_fixed_point(true)
490            .with_objective_scale(Some(
491                prepared
492                    .effective_observations
493                    .checked_mul(prepared.n_outputs)
494                    .ok_or_else(|| invalid("effective observation count overflow"))?
495                    as f64,
496            ));
497        if let Some(initial) = initial_log_lambdas.as_ref() {
498            problem = problem.with_initial_rho(Array1::from_iter(
499                prepared
500                    .penalties
501                    .iter()
502                    .map(|penalty| initial[penalty.output_slot]),
503            ));
504        }
505        let mut objective = SharedTangentObjective {
506            prepared: &prepared,
507        };
508        let outer = problem.run(&mut objective, FIT_CONTEXT)?;
509        let certificate = outer
510            .criterion_certificate
511            .clone()
512            .filter(OuterCriterionCertificate::certifies)
513            .ok_or_else(|| EstimationError::RemlDidNotConverge {
514                context: FIT_CONTEXT.to_string(),
515                reason: "outer runner returned without a valid analytic certificate".to_string(),
516                iterations: outer.iterations,
517                final_value: outer.final_value,
518                projected_grad_norm: outer
519                    .criterion_certificate
520                    .as_ref()
521                    .map(|value| value.stationarity.projected_norm()),
522                stationarity_bound: outer
523                    .criterion_certificate
524                    .as_ref()
525                    .map_or(0.0, |value| value.stationarity.bound()),
526                rho_checkpoint: outer.rho.to_vec(),
527            })?;
528        (outer.rho, outer.iterations, certificate)
529    };
530
531    let evaluation = prepared.evaluate(&rho)?;
532    let fitted = predict_from_coefficients(&prepared.design, &evaluation.coefficients)?;
533    let mut lambdas = Array1::<f64>::zeros(prepared.output_penalty_slots);
534    let mut edf_by_penalty = Array1::<f64>::zeros(prepared.output_penalty_slots);
535    for (active_index, penalty) in prepared.penalties.iter().enumerate() {
536        lambdas[penalty.output_slot] = evaluation.lambdas[active_index];
537        let upper = (penalty.rank * prepared.n_outputs) as f64;
538        let raw = upper - evaluation.penalty_traces[active_index];
539        edf_by_penalty[penalty.output_slot] =
540            bounded_roundoff_value(raw, 0.0, upper, "per-penalty effective degrees of freedom")?;
541    }
542    let total_coefficients = prepared
543        .n_coefficients
544        .checked_mul(prepared.n_outputs)
545        .ok_or_else(|| invalid("coefficient dimension overflow"))?
546        as f64;
547    let edf_total = bounded_roundoff_value(
548        total_coefficients - evaluation.penalty_traces.sum(),
549        0.0,
550        total_coefficients,
551        "total effective degrees of freedom",
552    )?;
553    let effective_joint_rows = prepared
554        .effective_observations
555        .checked_mul(prepared.n_outputs)
556        .ok_or_else(|| invalid("effective joint row count overflow"))?
557        as f64;
558    let residual_df = effective_joint_rows - edf_total;
559    if !(residual_df.is_finite() && residual_df > 0.0) {
560        return Err(invalid(format!(
561            "residual scale requires positive n*D-edf; got {effective_joint_rows} - {edf_total} = {residual_df}"
562        )));
563    }
564
565    Ok(SharedTangentRemlFit {
566        coefficients: evaluation.coefficients,
567        fitted,
568        sigma2: evaluation.profiled_deviance / residual_df,
569        lambdas,
570        edf_by_penalty,
571        edf_total,
572        reml_score: evaluation.cost,
573        n_observations: prepared.n_observations,
574        n_outputs: prepared.n_outputs,
575        outer_iterations,
576        outer_certificate: certificate,
577    })
578}
579
580struct SharedTangentObjective<'a> {
581    prepared: &'a PreparedSharedTangent,
582}
583
584impl OuterObjective for SharedTangentObjective<'_> {
585    fn capability(&self) -> OuterCapability {
586        OuterCapability {
587            gradient: Derivative::Analytic,
588            hessian: DeclaredHessianForm::Dense,
589            n_params: self.prepared.penalties.len(),
590            psi_dim: 0,
591            fixed_point_available: false,
592            barrier_config: None,
593            prefer_gradient_only: false,
594            disable_fixed_point: true,
595        }
596    }
597
598    fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
599        self.prepared
600            .evaluate(rho)
601            .map(|evaluation| evaluation.cost)
602    }
603
604    fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
605        let evaluation = self.prepared.evaluate(rho)?;
606        Ok(OuterEval {
607            cost: evaluation.cost,
608            gradient: evaluation.gradient,
609            hessian: HessianValue::Dense(evaluation.hessian),
610            inner_beta_hint: None,
611        })
612    }
613
614    fn reset(&mut self) {}
615
616    fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
617        // No warm-start slot to fill, but a non-finite seed is a caller error
618        // worth surfacing rather than silently discarding.
619        if beta.iter().any(|value| !value.is_finite()) {
620            return Err(invalid(
621                "seed_inner_state received a non-finite β warm-start vector",
622            ));
623        }
624        Ok(SeedOutcome::NoSlot)
625    }
626}
627
628impl PreparedSharedTangent {
629    fn from_request(request: SharedTangentRemlRequest) -> Result<Self, EstimationError> {
630        let SharedTangentRemlRequest {
631            design,
632            response,
633            weights,
634            fisher_metric,
635            penalties: requested_penalties,
636            initial_log_lambdas: _,
637        } = request;
638        let n = design.nrows();
639        let k = design.ncols();
640        let (response_rows, d) = response.dim();
641        if n == 0 || k == 0 || d == 0 {
642            return Err(invalid(format!(
643                "shared-tangent REML requires non-empty dimensions; got N={n}, K={k}, D={d}"
644            )));
645        }
646        if response_rows != n {
647            return Err(invalid(format!(
648                "response rows {response_rows} do not match design rows {n}"
649            )));
650        }
651        if weights.len() != n {
652            return Err(invalid(format!(
653                "weight length {} does not match design rows {n}",
654                weights.len()
655            )));
656        }
657        if response.iter().any(|value| !value.is_finite()) {
658            return Err(invalid("response must contain only finite values"));
659        }
660        if weights
661            .iter()
662            .any(|value| !value.is_finite() || *value < 0.0)
663        {
664            return Err(invalid("weights must be finite and non-negative"));
665        }
666        let effective_observations = weights.iter().filter(|value| **value > 0.0).count();
667        if effective_observations == 0 {
668            return Err(invalid(
669                "at least one observation must have positive weight",
670            ));
671        }
672        if let Some(metric) = fisher_metric.as_ref()
673            && metric.dim() != (n, d, d)
674        {
675            return Err(invalid(format!(
676                "fisher_metric shape {:?} does not match ({n}, {d}, {d})",
677                metric.dim()
678            )));
679        }
680        let fisher_metric = if let Some(metric) = fisher_metric {
681            let mut validated = Array3::<f64>::zeros(metric.dim());
682            for row in 0..n {
683                let row_metric = validated_metric(metric.slice(s![row, .., ..]).to_owned(), row)?;
684                validated.slice_mut(s![row, .., ..]).assign(&row_metric);
685            }
686            Some(validated)
687        } else {
688            None
689        };
690
691        let penalties = prepare_penalties(&requested_penalties, k)?;
692        let output_penalty_slots = requested_penalties.len();
693        let statistics = match fisher_metric.as_ref() {
694            None => assemble_isotropic_statistics(&design, &response, &weights)?,
695            Some(metric) => assemble_fisher_statistics(&design, &response, &weights, metric)?,
696        };
697
698        Ok(Self {
699            design,
700            response,
701            weights,
702            fisher_metric,
703            n_observations: n,
704            n_coefficients: k,
705            n_outputs: d,
706            effective_observations,
707            output_penalty_slots,
708            penalties,
709            statistics,
710        })
711    }
712
713    fn evaluate(&self, rho: &Array1<f64>) -> Result<Evaluation, EstimationError> {
714        if rho.len() != self.penalties.len() {
715            return Err(invalid(format!(
716                "log-lambda length {} does not match active penalty count {}",
717                rho.len(),
718                self.penalties.len()
719            )));
720        }
721        gam_problem::validate_log_strengths(rho.iter().copied())
722            .map_err(|error| invalid(format!("shared-tangent rho: {error}")))?;
723        match &self.statistics {
724            SufficientStatistics::Isotropic { gram, cross } => {
725                self.evaluate_isotropic(rho, gram, cross)
726            }
727            SufficientStatistics::Fisher { gram, cross } => self.evaluate_fisher(rho, gram, cross),
728        }
729    }
730
731    fn evaluate_isotropic(
732        &self,
733        rho: &Array1<f64>,
734        gram: &Array2<f64>,
735        cross: &Array2<f64>,
736    ) -> Result<Evaluation, EstimationError> {
737        let d = self.n_outputs;
738        let (penalty, lambdas) = self.combined_penalty(rho)?;
739        let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
740        let mut penalized = gram.clone();
741        penalized += &penalty;
742        let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
743        let coefficients = inverse.dot(cross);
744        let profiled_deviance = self.profiled_deviance(&coefficients)?;
745        let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
746        validate_profiled_deviance(profiled_deviance)?;
747
748        let m = self.penalties.len();
749        let mut penalty_traces = Array1::<f64>::zeros(m);
750        let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
751        let mut deviance_first = Array1::<f64>::zeros(m);
752        let mut penalty_beta = Vec::with_capacity(m);
753        for (index, penalty_block) in self.penalties.iter().enumerate() {
754            penalty_traces[index] =
755                d as f64 * trace_local_base(&inverse, penalty_block, lambdas[index]);
756            penalty_logdet_traces[index] = d as f64
757                * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
758            let z = apply_local_base_matrix(penalty_block, lambdas[index], &coefficients);
759            deviance_first[index] = sum_products(&coefficients, &z);
760            penalty_beta.push(z);
761        }
762
763        let mut gradient = Array1::<f64>::zeros(m);
764        for j in 0..m {
765            gradient[j] = 0.5
766                * (penalty_traces[j] - penalty_logdet_traces[j]
767                    + residual_degrees_of_freedom * deviance_first[j] / profiled_deviance);
768        }
769        let mut hessian = Array2::<f64>::zeros((m, m));
770        for j in 0..m {
771            let h_sandwich = sandwich_local_base(&inverse, &self.penalties[j], lambdas[j]);
772            let p_sandwich =
773                sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
774            for kk in 0..=j {
775                let h_cross = d as f64
776                    * trace_sandwich_local_base(&h_sandwich, &self.penalties[kk], lambdas[kk]);
777                let p_cross = d as f64
778                    * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
779                let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
780                let deviance_cross = sum_products(&penalty_beta[j], &solved_penalty_beta);
781                let delta = usize::from(j == kk) as f64;
782                let logdet_second = delta * penalty_traces[j] - h_cross;
783                let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
784                let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
785                let value = 0.5
786                    * (logdet_second - penalty_logdet_second
787                        + residual_degrees_of_freedom
788                            * (deviance_second / profiled_deviance
789                                - deviance_first[j] * deviance_first[kk]
790                                    / (profiled_deviance * profiled_deviance)));
791                hessian[[j, kk]] = value;
792                hessian[[kk, j]] = value;
793            }
794        }
795        let cost = 0.5
796            * (d as f64 * log_determinant - d as f64 * spectrum.log_pseudo_determinant
797                + residual_degrees_of_freedom
798                    * (1.0
799                        + (2.0 * std::f64::consts::PI * profiled_deviance
800                            / residual_degrees_of_freedom)
801                            .ln()));
802        validate_evaluation(cost, &gradient, &hessian)?;
803        Ok(Evaluation {
804            cost,
805            gradient,
806            hessian,
807            coefficients,
808            profiled_deviance,
809            penalty_traces,
810            lambdas,
811        })
812    }
813
814    fn evaluate_fisher(
815        &self,
816        rho: &Array1<f64>,
817        gram: &Array2<f64>,
818        cross: &Array1<f64>,
819    ) -> Result<Evaluation, EstimationError> {
820        let k = self.n_coefficients;
821        let d = self.n_outputs;
822        let q = k
823            .checked_mul(d)
824            .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
825        let (penalty, lambdas) = self.combined_penalty(rho)?;
826        let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
827        let mut penalized = gram.clone();
828        add_base_penalty_to_joint(&mut penalized, &penalty, d);
829        let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
830        let beta = inverse.dot(cross);
831        let mut coefficients = Array2::<f64>::zeros((k, d));
832        for basis in 0..k {
833            for output in 0..d {
834                coefficients[[basis, output]] = beta[basis * d + output];
835            }
836        }
837        let profiled_deviance = self.profiled_deviance(&coefficients)?;
838        validate_profiled_deviance(profiled_deviance)?;
839        let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
840
841        let m = self.penalties.len();
842        let mut penalty_traces = Array1::<f64>::zeros(m);
843        let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
844        let mut deviance_first = Array1::<f64>::zeros(m);
845        let mut penalty_beta = Vec::with_capacity(m);
846        for (index, penalty_block) in self.penalties.iter().enumerate() {
847            penalty_traces[index] = trace_local_joint(&inverse, penalty_block, lambdas[index], d);
848            penalty_logdet_traces[index] = d as f64
849                * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
850            let z = apply_local_joint_vector(penalty_block, lambdas[index], d, &beta);
851            deviance_first[index] = beta.dot(&z);
852            penalty_beta.push(z);
853        }
854
855        let mut gradient = Array1::<f64>::zeros(m);
856        for j in 0..m {
857            gradient[j] = 0.5
858                * (penalty_traces[j] - penalty_logdet_traces[j]
859                    + residual_degrees_of_freedom * deviance_first[j] / profiled_deviance);
860        }
861        let mut hessian = Array2::<f64>::zeros((m, m));
862        for j in 0..m {
863            let h_sandwich = sandwich_local_joint(&inverse, &self.penalties[j], lambdas[j], d);
864            let p_sandwich =
865                sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
866            for kk in 0..=j {
867                let h_cross =
868                    trace_sandwich_local_joint(&h_sandwich, &self.penalties[kk], lambdas[kk], d);
869                let p_cross = d as f64
870                    * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
871                let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
872                let deviance_cross = penalty_beta[j].dot(&solved_penalty_beta);
873                let delta = usize::from(j == kk) as f64;
874                let logdet_second = delta * penalty_traces[j] - h_cross;
875                let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
876                let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
877                let value = 0.5
878                    * (logdet_second - penalty_logdet_second
879                        + residual_degrees_of_freedom
880                            * (deviance_second / profiled_deviance
881                                - deviance_first[j] * deviance_first[kk]
882                                    / (profiled_deviance * profiled_deviance)));
883                hessian[[j, kk]] = value;
884                hessian[[kk, j]] = value;
885            }
886        }
887        let cost = 0.5
888            * (log_determinant - d as f64 * spectrum.log_pseudo_determinant
889                + residual_degrees_of_freedom
890                    * (1.0
891                        + (2.0 * std::f64::consts::PI * profiled_deviance
892                            / residual_degrees_of_freedom)
893                            .ln()));
894        if inverse.dim() != (q, q) {
895            return Err(invalid("internal Fisher inverse shape mismatch"));
896        }
897        validate_evaluation(cost, &gradient, &hessian)?;
898        Ok(Evaluation {
899            cost,
900            gradient,
901            hessian,
902            coefficients,
903            profiled_deviance,
904            penalty_traces,
905            lambdas,
906        })
907    }
908
909    /// Evaluate the fitted weighted residual quadratic directly from row
910    /// chunks.  Forming it as `y'Wy - (X'Wy)' beta` catastrophically cancels
911    /// on near-interpolating fits; the resulting few ulps are large relative to
912    /// the residual itself and can move a flat REML optimum by many nats under
913    /// an otherwise harmless rotation of the tangent frame.
914    fn profiled_deviance(&self, coefficients: &Array2<f64>) -> Result<f64, EstimationError> {
915        let n = self.design.nrows();
916        let k = self.design.ncols();
917        let d = self.response.ncols();
918        if coefficients.dim() != (k, d) {
919            return Err(invalid(format!(
920                "shared-tangent coefficient shape {:?} does not match ({k}, {d})",
921                coefficients.dim()
922            )));
923        }
924        let mut quadratic = KahanSum::default();
925        let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
926        for start in (0..n).step_by(chunk_rows) {
927            let end = (start + chunk_rows).min(n);
928            let x_chunk = self
929                .design
930                .try_row_chunk(start..end)
931                .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
932            validate_design_chunk(&x_chunk)?;
933            let fitted = x_chunk.dot(coefficients);
934            for local_row in 0..x_chunk.nrows() {
935                let row = start + local_row;
936                let weight = self.weights[row];
937                if weight == 0.0 {
938                    continue;
939                }
940                if let Some(metric) = self.fisher_metric.as_ref() {
941                    for output_a in 0..d {
942                        let residual_a =
943                            self.response[[row, output_a]] - fitted[[local_row, output_a]];
944                        for output_b in 0..d {
945                            let residual_b =
946                                self.response[[row, output_b]] - fitted[[local_row, output_b]];
947                            quadratic.add(
948                                weight
949                                    * residual_a
950                                    * metric[[row, output_a, output_b]]
951                                    * residual_b,
952                            );
953                        }
954                    }
955                } else {
956                    for output in 0..d {
957                        let residual = self.response[[row, output]] - fitted[[local_row, output]];
958                        quadratic.add(weight * residual * residual);
959                    }
960                }
961            }
962        }
963        Ok(quadratic.sum())
964    }
965
966    fn combined_penalty(
967        &self,
968        rho: &Array1<f64>,
969    ) -> Result<(Array2<f64>, Array1<f64>), EstimationError> {
970        let mut combined = Array2::<f64>::zeros((self.n_coefficients, self.n_coefficients));
971        let lambdas = Array1::from_vec(
972            gam_problem::checked_exp_log_strengths(rho.iter().copied())
973                .map_err(|error| invalid(format!("shared-tangent rho: {error}")))?,
974        );
975        for (index, penalty) in self.penalties.iter().enumerate() {
976            let lambda = lambdas[index];
977            for local_row in 0..penalty.local.nrows() {
978                for local_col in 0..penalty.local.ncols() {
979                    combined[[
980                        penalty.column_start + local_row,
981                        penalty.column_start + local_col,
982                    ]] += lambda * penalty.local[[local_row, local_col]];
983                }
984            }
985        }
986        Ok((combined, lambdas))
987    }
988
989    fn residual_degrees_of_freedom(
990        &self,
991        combined_penalty_rank: usize,
992    ) -> Result<f64, EstimationError> {
993        let effective_rows = self
994            .effective_observations
995            .checked_mul(self.n_outputs)
996            .ok_or_else(|| invalid("effective joint row count overflow"))?;
997        let base_nullity = self
998            .n_coefficients
999            .checked_sub(combined_penalty_rank)
1000            .ok_or_else(|| invalid("combined penalty rank exceeds coefficient dimension"))?;
1001        let joint_nullity = base_nullity
1002            .checked_mul(self.n_outputs)
1003            .ok_or_else(|| invalid("joint penalty nullity overflow"))?;
1004        if effective_rows <= joint_nullity {
1005            return Err(invalid(format!(
1006                "REML requires more effective joint rows than unpenalized coefficients; got {effective_rows} rows and nullity {joint_nullity}"
1007            )));
1008        }
1009        Ok((effective_rows - joint_nullity) as f64)
1010    }
1011}
1012
1013fn prepare_penalties(
1014    penalties: &[SharedTangentPenalty],
1015    n_coefficients: usize,
1016) -> Result<Vec<PreparedPenalty>, EstimationError> {
1017    let mut prepared = Vec::with_capacity(penalties.len());
1018    for (slot, penalty) in penalties.iter().enumerate() {
1019        let q = penalty.matrix.nrows();
1020        if q != penalty.matrix.ncols() {
1021            return Err(invalid(format!(
1022                "penalty {slot} must be square; got {}x{}",
1023                penalty.matrix.nrows(),
1024                penalty.matrix.ncols()
1025            )));
1026        }
1027        let end = penalty
1028            .column_start
1029            .checked_add(q)
1030            .ok_or_else(|| invalid(format!("penalty {slot} column range overflow")))?;
1031        if end > n_coefficients {
1032            return Err(invalid(format!(
1033                "penalty {slot} column range {}..{end} exceeds design width {n_coefficients}",
1034                penalty.column_start
1035            )));
1036        }
1037        if penalty.matrix.iter().any(|value| !value.is_finite()) {
1038            return Err(invalid(format!(
1039                "penalty {slot} contains non-finite values"
1040            )));
1041        }
1042        let local = symmetric_average(&penalty.matrix);
1043        let spectrum = penalty_spectrum(&local, &format!("shared-tangent penalty {slot}"))?;
1044        if spectrum.rank == 0 {
1045            continue;
1046        }
1047        prepared.push(PreparedPenalty {
1048            output_slot: slot,
1049            column_start: penalty.column_start,
1050            local,
1051            rank: spectrum.rank,
1052        });
1053    }
1054    Ok(prepared)
1055}
1056
1057fn assemble_isotropic_statistics(
1058    design: &DesignMatrix,
1059    response: &Array2<f64>,
1060    weights: &Array1<f64>,
1061) -> Result<SufficientStatistics, EstimationError> {
1062    let n = design.nrows();
1063    let k = design.ncols();
1064    let d = response.ncols();
1065    let mut gram = Array2::<f64>::zeros((k, k));
1066    let mut cross = Array2::<f64>::zeros((k, d));
1067    let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1068    for start in (0..n).step_by(chunk_rows) {
1069        let end = (start + chunk_rows).min(n);
1070        let x_chunk = design
1071            .try_row_chunk(start..end)
1072            .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1073        validate_design_chunk(&x_chunk)?;
1074        let weight_chunk = weights.slice(s![start..end]);
1075        let response_chunk = response.slice(s![start..end, ..]);
1076        gram += &fast_xt_diag_x(&x_chunk, &weight_chunk);
1077        cross += &fast_xt_diag_y(&x_chunk, &weight_chunk, &response_chunk);
1078    }
1079    Ok(SufficientStatistics::Isotropic { gram, cross })
1080}
1081
1082fn assemble_fisher_statistics(
1083    design: &DesignMatrix,
1084    response: &Array2<f64>,
1085    weights: &Array1<f64>,
1086    fisher_metric: &Array3<f64>,
1087) -> Result<SufficientStatistics, EstimationError> {
1088    let n = design.nrows();
1089    let k = design.ncols();
1090    let d = response.ncols();
1091    let q = k
1092        .checked_mul(d)
1093        .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
1094    let mut gram = Array2::<f64>::zeros((q, q));
1095    let mut cross = Array1::<f64>::zeros(q);
1096    let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1097    for start in (0..n).step_by(chunk_rows) {
1098        let end = (start + chunk_rows).min(n);
1099        let x_chunk = design
1100            .try_row_chunk(start..end)
1101            .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1102        validate_design_chunk(&x_chunk)?;
1103        for local_row in 0..x_chunk.nrows() {
1104            let row = start + local_row;
1105            let metric = fisher_metric.slice(s![row, .., ..]);
1106            let y = response.row(row);
1107            let metric_y = metric.dot(&y);
1108            let weight = weights[row];
1109            for basis_a in 0..k {
1110                let x_a = x_chunk[[local_row, basis_a]];
1111                for output in 0..d {
1112                    cross[basis_a * d + output] += weight * x_a * metric_y[output];
1113                }
1114                for basis_b in 0..k {
1115                    let scale = weight * x_a * x_chunk[[local_row, basis_b]];
1116                    if scale == 0.0 {
1117                        continue;
1118                    }
1119                    for output_a in 0..d {
1120                        for output_b in 0..d {
1121                            gram[[basis_a * d + output_a, basis_b * d + output_b]] +=
1122                                scale * metric[[output_a, output_b]];
1123                        }
1124                    }
1125                }
1126            }
1127        }
1128    }
1129    Ok(SufficientStatistics::Fisher { gram, cross })
1130}
1131
1132fn validated_metric(mut metric: Array2<f64>, row: usize) -> Result<Array2<f64>, EstimationError> {
1133    if metric.iter().any(|value| !value.is_finite()) {
1134        return Err(invalid(format!(
1135            "fisher_metric row {row} contains non-finite values"
1136        )));
1137    }
1138    let scale = metric
1139        .iter()
1140        .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1141    let tolerance = f64::EPSILON.sqrt() * metric.nrows().max(1) as f64 * scale;
1142    for a in 0..metric.nrows() {
1143        for b in (a + 1)..metric.ncols() {
1144            if (metric[[a, b]] - metric[[b, a]]).abs() > tolerance {
1145                return Err(invalid(format!(
1146                    "fisher_metric row {row} is not symmetric at ({a}, {b})"
1147                )));
1148            }
1149            let average = 0.5 * (metric[[a, b]] + metric[[b, a]]);
1150            metric[[a, b]] = average;
1151            metric[[b, a]] = average;
1152        }
1153    }
1154    metric.cholesky(Side::Lower).map_err(|error| {
1155        invalid(format!(
1156            "fisher_metric row {row} must be positive definite: {error}"
1157        ))
1158    })?;
1159    Ok(metric)
1160}
1161
1162fn penalty_spectrum(
1163    penalty: &Array2<f64>,
1164    context: &str,
1165) -> Result<PenaltySpectrum, EstimationError> {
1166    let (eigenvalues, eigenvectors) = penalty
1167        .eigh(Side::Lower)
1168        .map_err(EstimationError::EigendecompositionFailed)?;
1169    let scale = eigenvalues
1170        .iter()
1171        .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1172    let tolerance = f64::EPSILON.sqrt() * eigenvalues.len().max(1) as f64 * scale;
1173    let mut rank = 0usize;
1174    let mut log_pseudo_determinant = 0.0;
1175    let mut pseudo_inverse = Array2::<f64>::zeros(penalty.dim());
1176    for (index, &value) in eigenvalues.iter().enumerate() {
1177        if !value.is_finite() {
1178            return Err(EstimationError::PenaltySpectrumNonFinite {
1179                context: context.to_string(),
1180                index,
1181                value,
1182            });
1183        }
1184        if value < -tolerance {
1185            return Err(EstimationError::PenaltySpectrumIndefinite {
1186                context: context.to_string(),
1187                index,
1188                value,
1189                tolerance,
1190                scale,
1191            });
1192        }
1193        if value <= tolerance {
1194            continue;
1195        }
1196        rank += 1;
1197        log_pseudo_determinant += value.ln();
1198        for row in 0..penalty.nrows() {
1199            for col in 0..penalty.ncols() {
1200                pseudo_inverse[[row, col]] +=
1201                    eigenvectors[[row, index]] * eigenvectors[[col, index]] / value;
1202            }
1203        }
1204    }
1205    Ok(PenaltySpectrum {
1206        rank,
1207        log_pseudo_determinant,
1208        pseudo_inverse,
1209    })
1210}
1211
1212fn spd_inverse_and_logdet(matrix: &Array2<f64>) -> Result<(Array2<f64>, f64), EstimationError> {
1213    let factor =
1214        matrix
1215            .cholesky(Side::Lower)
1216            .map_err(|_| EstimationError::ModelIsIllConditioned {
1217                condition_number: f64::INFINITY,
1218            })?;
1219    let diagonal = factor.diag();
1220    if diagonal
1221        .iter()
1222        .any(|value| !value.is_finite() || *value <= 0.0)
1223    {
1224        return Err(EstimationError::ModelIsIllConditioned {
1225            condition_number: f64::INFINITY,
1226        });
1227    }
1228    let log_determinant = 2.0 * diagonal.iter().map(|value| value.ln()).sum::<f64>();
1229    let identity = Array2::<f64>::eye(matrix.nrows());
1230    let inverse = factor.solve_mat(&identity);
1231    if !log_determinant.is_finite() || inverse.iter().any(|value| !value.is_finite()) {
1232        return Err(EstimationError::ModelIsIllConditioned {
1233            condition_number: f64::INFINITY,
1234        });
1235    }
1236    Ok((inverse, log_determinant))
1237}
1238
1239fn trace_local_base(inverse: &Array2<f64>, penalty: &PreparedPenalty, lambda: f64) -> f64 {
1240    let mut trace = 0.0;
1241    for row in 0..penalty.local.nrows() {
1242        for col in 0..penalty.local.ncols() {
1243            trace += lambda
1244                * penalty.local[[row, col]]
1245                * inverse[[penalty.column_start + col, penalty.column_start + row]];
1246        }
1247    }
1248    trace
1249}
1250
1251fn trace_local_joint(
1252    inverse: &Array2<f64>,
1253    penalty: &PreparedPenalty,
1254    lambda: f64,
1255    n_outputs: usize,
1256) -> f64 {
1257    let mut trace = 0.0;
1258    for output in 0..n_outputs {
1259        for row in 0..penalty.local.nrows() {
1260            for col in 0..penalty.local.ncols() {
1261                trace += lambda
1262                    * penalty.local[[row, col]]
1263                    * inverse[[
1264                        (penalty.column_start + col) * n_outputs + output,
1265                        (penalty.column_start + row) * n_outputs + output,
1266                    ]];
1267            }
1268        }
1269    }
1270    trace
1271}
1272
1273fn apply_local_base_matrix(
1274    penalty: &PreparedPenalty,
1275    lambda: f64,
1276    matrix: &Array2<f64>,
1277) -> Array2<f64> {
1278    let mut output = Array2::<f64>::zeros(matrix.dim());
1279    for row in 0..penalty.local.nrows() {
1280        for col in 0..penalty.local.ncols() {
1281            let value = lambda * penalty.local[[row, col]];
1282            for output_index in 0..matrix.ncols() {
1283                output[[penalty.column_start + row, output_index]] +=
1284                    value * matrix[[penalty.column_start + col, output_index]];
1285            }
1286        }
1287    }
1288    output
1289}
1290
1291fn apply_local_joint_vector(
1292    penalty: &PreparedPenalty,
1293    lambda: f64,
1294    n_outputs: usize,
1295    vector: &Array1<f64>,
1296) -> Array1<f64> {
1297    let mut output = Array1::<f64>::zeros(vector.len());
1298    for output_index in 0..n_outputs {
1299        for row in 0..penalty.local.nrows() {
1300            for col in 0..penalty.local.ncols() {
1301                output[(penalty.column_start + row) * n_outputs + output_index] += lambda
1302                    * penalty.local[[row, col]]
1303                    * vector[(penalty.column_start + col) * n_outputs + output_index];
1304            }
1305        }
1306    }
1307    output
1308}
1309
1310fn sandwich_local_base(
1311    inverse: &Array2<f64>,
1312    penalty: &PreparedPenalty,
1313    lambda: f64,
1314) -> Array2<f64> {
1315    let dimension = inverse.nrows();
1316    let mut result = Array2::<f64>::zeros((dimension, dimension));
1317    for local_row in 0..penalty.local.nrows() {
1318        let global_row = penalty.column_start + local_row;
1319        for local_col in 0..penalty.local.ncols() {
1320            let value = lambda * penalty.local[[local_row, local_col]];
1321            if value == 0.0 {
1322                continue;
1323            }
1324            let global_col = penalty.column_start + local_col;
1325            for row in 0..dimension {
1326                let left = inverse[[row, global_row]] * value;
1327                for col in 0..dimension {
1328                    result[[row, col]] += left * inverse[[global_col, col]];
1329                }
1330            }
1331        }
1332    }
1333    result
1334}
1335
1336fn sandwich_local_joint(
1337    inverse: &Array2<f64>,
1338    penalty: &PreparedPenalty,
1339    lambda: f64,
1340    n_outputs: usize,
1341) -> Array2<f64> {
1342    let dimension = inverse.nrows();
1343    let mut result = Array2::<f64>::zeros((dimension, dimension));
1344    for output in 0..n_outputs {
1345        for local_row in 0..penalty.local.nrows() {
1346            let global_row = (penalty.column_start + local_row) * n_outputs + output;
1347            for local_col in 0..penalty.local.ncols() {
1348                let value = lambda * penalty.local[[local_row, local_col]];
1349                if value == 0.0 {
1350                    continue;
1351                }
1352                let global_col = (penalty.column_start + local_col) * n_outputs + output;
1353                for row in 0..dimension {
1354                    let left = inverse[[row, global_row]] * value;
1355                    for col in 0..dimension {
1356                        result[[row, col]] += left * inverse[[global_col, col]];
1357                    }
1358                }
1359            }
1360        }
1361    }
1362    result
1363}
1364
1365fn trace_sandwich_local_base(
1366    sandwich: &Array2<f64>,
1367    penalty: &PreparedPenalty,
1368    lambda: f64,
1369) -> f64 {
1370    let mut trace = 0.0;
1371    for row in 0..penalty.local.nrows() {
1372        for col in 0..penalty.local.ncols() {
1373            trace += lambda
1374                * penalty.local[[row, col]]
1375                * sandwich[[penalty.column_start + col, penalty.column_start + row]];
1376        }
1377    }
1378    trace
1379}
1380
1381fn trace_sandwich_local_joint(
1382    sandwich: &Array2<f64>,
1383    penalty: &PreparedPenalty,
1384    lambda: f64,
1385    n_outputs: usize,
1386) -> f64 {
1387    let mut trace = 0.0;
1388    for output in 0..n_outputs {
1389        for row in 0..penalty.local.nrows() {
1390            for col in 0..penalty.local.ncols() {
1391                trace += lambda
1392                    * penalty.local[[row, col]]
1393                    * sandwich[[
1394                        (penalty.column_start + col) * n_outputs + output,
1395                        (penalty.column_start + row) * n_outputs + output,
1396                    ]];
1397            }
1398        }
1399    }
1400    trace
1401}
1402
1403fn add_base_penalty_to_joint(joint: &mut Array2<f64>, penalty: &Array2<f64>, n_outputs: usize) {
1404    for row in 0..penalty.nrows() {
1405        for col in 0..penalty.ncols() {
1406            let value = penalty[[row, col]];
1407            for output in 0..n_outputs {
1408                joint[[row * n_outputs + output, col * n_outputs + output]] += value;
1409            }
1410        }
1411    }
1412}
1413
1414fn symmetric_average(matrix: &Array2<f64>) -> Array2<f64> {
1415    let mut output = matrix.clone();
1416    for row in 0..matrix.nrows() {
1417        for col in (row + 1)..matrix.ncols() {
1418            let average = 0.5 * (matrix[[row, col]] + matrix[[col, row]]);
1419            output[[row, col]] = average;
1420            output[[col, row]] = average;
1421        }
1422    }
1423    output
1424}
1425
1426fn predict_from_coefficients(
1427    design: &DesignMatrix,
1428    coefficients: &Array2<f64>,
1429) -> Result<Array2<f64>, EstimationError> {
1430    if design.ncols() != coefficients.nrows() {
1431        return Err(invalid(format!(
1432            "prediction design width {} does not match coefficient rows {}",
1433            design.ncols(),
1434            coefficients.nrows()
1435        )));
1436    }
1437    let mut prediction = Array2::<f64>::zeros((design.nrows(), coefficients.ncols()));
1438    for output in 0..coefficients.ncols() {
1439        let values = design.apply(&coefficients.column(output).to_owned());
1440        prediction.column_mut(output).assign(&values);
1441    }
1442    if prediction.iter().any(|value| !value.is_finite()) {
1443        return Err(invalid("prediction produced non-finite values"));
1444    }
1445    Ok(prediction)
1446}
1447
1448fn sum_products(left: &Array2<f64>, right: &Array2<f64>) -> f64 {
1449    left.iter()
1450        .zip(right.iter())
1451        .map(|(left, right)| left * right)
1452        .sum()
1453}
1454
1455fn validate_design_chunk(chunk: &Array2<f64>) -> Result<(), EstimationError> {
1456    if chunk.iter().any(|value| !value.is_finite()) {
1457        return Err(invalid("design contains non-finite values"));
1458    }
1459    Ok(())
1460}
1461
1462fn validate_profiled_deviance(value: f64) -> Result<(), EstimationError> {
1463    if !value.is_finite() || value <= 0.0 {
1464        return Err(EstimationError::RemlOptimizationFailed(format!(
1465            "{FIT_CONTEXT}: profiled penalized deviance must be finite and positive, got {value}"
1466        )));
1467    }
1468    Ok(())
1469}
1470
1471fn validate_evaluation(
1472    cost: f64,
1473    gradient: &Array1<f64>,
1474    hessian: &Array2<f64>,
1475) -> Result<(), EstimationError> {
1476    if !cost.is_finite()
1477        || gradient.iter().any(|value| !value.is_finite())
1478        || hessian.iter().any(|value| !value.is_finite())
1479    {
1480        return Err(EstimationError::RemlOptimizationFailed(format!(
1481            "{FIT_CONTEXT}: objective evaluation produced non-finite value or derivatives"
1482        )));
1483    }
1484    Ok(())
1485}
1486
1487fn validate_archived_tangent_fit(
1488    fit: &SharedTangentRemlFit,
1489) -> Result<(), ResponseGeometryModelError> {
1490    if fit.n_observations == 0
1491        || fit.n_outputs == 0
1492        || fit.coefficients.nrows() == 0
1493        || fit.coefficients.ncols() != fit.n_outputs
1494        || fit.fitted.dim() != (fit.n_observations, fit.n_outputs)
1495    {
1496        return Err(ResponseGeometryModelError::InvalidMetadata(
1497            "shared tangent fit has inconsistent dimensions".to_string(),
1498        ));
1499    }
1500    if fit.lambdas.len() != fit.edf_by_penalty.len() {
1501        return Err(ResponseGeometryModelError::InvalidMetadata(
1502            "shared tangent lambda and EDF vectors are misaligned".to_string(),
1503        ));
1504    }
1505    if fit.coefficients.iter().any(|value| !value.is_finite())
1506        || fit.fitted.iter().any(|value| !value.is_finite())
1507        || fit
1508            .lambdas
1509            .iter()
1510            .any(|value| !value.is_finite() || *value < 0.0)
1511        || fit
1512            .edf_by_penalty
1513            .iter()
1514            .any(|value| !value.is_finite() || *value < 0.0)
1515        || !fit.sigma2.is_finite()
1516        || fit.sigma2 <= 0.0
1517        || !fit.edf_total.is_finite()
1518        || fit.edf_total < 0.0
1519        || !fit.reml_score.is_finite()
1520    {
1521        return Err(ResponseGeometryModelError::InvalidMetadata(
1522            "shared tangent fit contains invalid numerical values".to_string(),
1523        ));
1524    }
1525    if !fit.outer_certificate.certifies() {
1526        return Err(ResponseGeometryModelError::InvalidMetadata(
1527            "shared tangent fit lacks a valid convergence certificate".to_string(),
1528        ));
1529    }
1530    Ok(())
1531}
1532
1533fn bounded_roundoff_value(
1534    value: f64,
1535    lower: f64,
1536    upper: f64,
1537    context: &str,
1538) -> Result<f64, EstimationError> {
1539    let tolerance = f64::EPSILON.sqrt() * upper.abs().max(1.0);
1540    if !value.is_finite() || value < lower - tolerance || value > upper + tolerance {
1541        return Err(EstimationError::RemlOptimizationFailed(format!(
1542            "{FIT_CONTEXT}: {context} {value} lies outside [{lower}, {upper}] beyond roundoff"
1543        )));
1544    }
1545    Ok(value.clamp(lower, upper))
1546}
1547
1548fn invalid(message: impl Into<String>) -> EstimationError {
1549    EstimationError::InvalidInput(message.into())
1550}
1551
1552#[cfg(test)]
1553mod tests {
1554    use super::*;
1555    use gam_linalg::test_support::no_densify_design;
1556    use ndarray::{Array3, array};
1557
1558    fn fixture_request(fisher_metric: Option<Array3<f64>>) -> SharedTangentRemlRequest {
1559        let design = array![
1560            [1.0, -1.0, 0.5],
1561            [1.0, -0.5, -0.2],
1562            [1.0, 0.0, 0.3],
1563            [1.0, 0.5, 0.8],
1564            [1.0, 1.0, -0.4],
1565            [1.0, 1.5, 0.1]
1566        ];
1567        let response = array![
1568            [-0.7, 0.4],
1569            [-0.1, 0.1],
1570            [0.2, -0.3],
1571            [0.8, -0.2],
1572            [1.1, 0.5],
1573            [1.7, 0.2]
1574        ];
1575        let penalties = vec![
1576            SharedTangentPenalty::new(1, array![[1.0, 0.0], [0.0, 0.0]]),
1577            SharedTangentPenalty::new(1, array![[0.0, 0.0], [0.0, 1.0]]),
1578        ];
1579        SharedTangentRemlRequest::new(
1580            no_densify_design(design),
1581            response,
1582            array![1.0, 0.8, 1.2, 1.0, 0.9, 1.1],
1583            fisher_metric,
1584            penalties,
1585        )
1586    }
1587
1588    #[test]
1589    fn operator_backed_isotropic_path_matches_streamed_identity_fisher_path() {
1590        let isotropic_request = fixture_request(None);
1591        let n = isotropic_request.response.nrows();
1592        let d = isotropic_request.response.ncols();
1593        let mut identity_metric = Array3::<f64>::zeros((n, d, d));
1594        for row in 0..n {
1595            for output in 0..d {
1596                identity_metric[[row, output, output]] = 1.0;
1597            }
1598        }
1599        let fisher_request = fixture_request(Some(identity_metric));
1600        let isotropic = PreparedSharedTangent::from_request(isotropic_request)
1601            .expect("prepare isotropic without densifying");
1602        let fisher = PreparedSharedTangent::from_request(fisher_request)
1603            .expect("prepare Fisher without densifying");
1604        let rho = array![-0.4, 0.7];
1605        let left = isotropic.evaluate(&rho).expect("isotropic eval");
1606        let right = fisher.evaluate(&rho).expect("Fisher eval");
1607        assert_close(left.cost, right.cost, 2.0e-11);
1608        assert_array1_close(&left.gradient, &right.gradient, 2.0e-10);
1609        assert_array2_close(&left.hessian, &right.hessian, 2.0e-9);
1610        assert_array2_close(&left.coefficients, &right.coefficients, 2.0e-11);
1611    }
1612
1613    #[test]
1614    fn analytic_gradient_and_hessian_match_test_only_finite_differences() {
1615        let request = fixture_request(None);
1616        let prepared = PreparedSharedTangent::from_request(request).expect("prepare");
1617        let rho = array![-0.2, 0.35];
1618        let exact = prepared.evaluate(&rho).expect("exact eval");
1619        let step = f64::EPSILON.cbrt();
1620        for j in 0..rho.len() {
1621            let mut plus = rho.clone();
1622            let mut minus = rho.clone();
1623            plus[j] += step;
1624            minus[j] -= step;
1625            let plus_eval = prepared.evaluate(&plus).expect("plus eval");
1626            let minus_eval = prepared.evaluate(&minus).expect("minus eval");
1627            let gradient_fd = (plus_eval.cost - minus_eval.cost) / (2.0 * step);
1628            assert_close(exact.gradient[j], gradient_fd, 2.0e-6);
1629            for k in 0..rho.len() {
1630                let hessian_fd = (plus_eval.gradient[k] - minus_eval.gradient[k]) / (2.0 * step);
1631                assert_close(exact.hessian[[k, j]], hessian_fd, 3.0e-6);
1632            }
1633        }
1634    }
1635
1636    #[test]
1637    fn streamed_varying_fisher_statistics_match_explicit_joint_oracle() {
1638        let base = fixture_request(None);
1639        let n = base.response.nrows();
1640        let d = base.response.ncols();
1641        let mut metric = Array3::<f64>::zeros((n, d, d));
1642        for row in 0..n {
1643            let off = 0.04 * (row as f64 + 1.0);
1644            metric[[row, 0, 0]] = 1.2 + 0.1 * row as f64;
1645            metric[[row, 0, 1]] = off;
1646            metric[[row, 1, 0]] = off;
1647            metric[[row, 1, 1]] = 0.9 + 0.05 * row as f64;
1648        }
1649        let request = fixture_request(Some(metric.clone()));
1650        let prepared =
1651            PreparedSharedTangent::from_request(request.clone()).expect("prepare Fisher");
1652        let SufficientStatistics::Fisher { gram, cross } = &prepared.statistics else {
1653            panic!("expected Fisher statistics")
1654        };
1655        let x = base.design.try_row_chunk(0..n).expect("test design rows");
1656        let k = x.ncols();
1657        let q = k * d;
1658        let mut oracle_gram = Array2::<f64>::zeros((q, q));
1659        let mut oracle_cross = Array1::<f64>::zeros(q);
1660        let mut oracle_response = 0.0;
1661        for row in 0..n {
1662            for a in 0..k {
1663                for o in 0..d {
1664                    let ao = a * d + o;
1665                    for p in 0..d {
1666                        oracle_cross[ao] += request.weights[row]
1667                            * x[[row, a]]
1668                            * metric[[row, o, p]]
1669                            * request.response[[row, p]];
1670                    }
1671                    for b in 0..k {
1672                        for p in 0..d {
1673                            oracle_gram[[ao, b * d + p]] += request.weights[row]
1674                                * x[[row, a]]
1675                                * x[[row, b]]
1676                                * metric[[row, o, p]];
1677                        }
1678                    }
1679                }
1680            }
1681            let y = request.response.row(row);
1682            oracle_response += request.weights[row] * y.dot(&metric.slice(s![row, .., ..]).dot(&y));
1683        }
1684        assert_array2_close(gram, &oracle_gram, 2.0e-12);
1685        assert_array1_close(cross, &oracle_cross, 2.0e-12);
1686        let zero_coefficients = Array2::<f64>::zeros((k, d));
1687        let direct_response_quadratic = prepared
1688            .profiled_deviance(&zero_coefficients)
1689            .expect("direct zero-fit quadratic");
1690        assert_close(direct_response_quadratic, oracle_response, 2.0e-12);
1691    }
1692
1693    #[test]
1694    fn parametric_fit_is_certified_serializable_and_predicts_in_core() {
1695        let design = array![[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
1696        let response = array![[0.2, -0.1], [0.9, 0.4], [2.1, 0.8], [2.8, 1.4]];
1697        let request = SharedTangentRemlRequest::from_dense(
1698            design.clone(),
1699            response,
1700            Array1::ones(4),
1701            None,
1702            Vec::new(),
1703        );
1704        let fit = fit_shared_tangent_reml(request).expect("certified parametric fit");
1705        assert!(fit.outer_certificate.certifies());
1706        let prediction = fit.predict_dense(design).expect("core prediction");
1707        assert_array2_close(&prediction, &fit.fitted, 1.0e-12);
1708        let encoded = serde_json::to_string(&fit).expect("serialize fit");
1709        let decoded: SharedTangentRemlFit =
1710            serde_json::from_str(&encoded).expect("deserialize fit");
1711        assert_array2_close(&decoded.coefficients, &fit.coefficients, 0.0);
1712        assert!(decoded.outer_certificate.certifies());
1713    }
1714
1715    fn assert_close(left: f64, right: f64, tolerance: f64) {
1716        let scale = left.abs().max(right.abs()).max(1.0);
1717        assert!(
1718            (left - right).abs() <= tolerance * scale,
1719            "{left} != {right} within relative tolerance {tolerance}"
1720        );
1721    }
1722
1723    fn assert_array1_close(left: &Array1<f64>, right: &Array1<f64>, tolerance: f64) {
1724        assert_eq!(left.len(), right.len());
1725        for (left, right) in left.iter().zip(right.iter()) {
1726            assert_close(*left, *right, tolerance);
1727        }
1728    }
1729
1730    fn assert_array2_close(left: &Array2<f64>, right: &Array2<f64>, tolerance: f64) {
1731        assert_eq!(left.dim(), right.dim());
1732        for (left, right) in left.iter().zip(right.iter()) {
1733            assert_close(*left, *right, tolerance);
1734        }
1735    }
1736}