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