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}
437
438#[derive(Debug)]
439struct PenaltySpectrum {
440    rank: usize,
441    log_pseudo_determinant: f64,
442    pseudo_inverse: Array2<f64>,
443}
444
445/// Fit a shared-smoothing multi-output Gaussian model by exact profiled REML.
446pub fn fit_shared_tangent_reml(
447    mut request: SharedTangentRemlRequest,
448) -> Result<SharedTangentRemlFit, EstimationError> {
449    let requested_penalty_count = request.penalties.len();
450    let initial_log_lambdas = request.initial_log_lambdas.take();
451    let prepared = PreparedSharedTangent::from_request(request)?;
452    let n_outer = prepared.penalties.len();
453    if let Some(initial) = initial_log_lambdas.as_ref() {
454        if initial.len() != requested_penalty_count {
455            return Err(invalid(format!(
456                "initial_log_lambdas has length {}, expected {}",
457                initial.len(),
458                requested_penalty_count
459            )));
460        }
461        if initial.iter().any(|value| !value.is_finite()) {
462            return Err(invalid("initial_log_lambdas must be finite"));
463        }
464    }
465    let (rho, outer_iterations, certificate) = if n_outer == 0 {
466        // A parametric model has no smoothing estimand. Its empty analytic
467        // score is exactly stationary and its empty Hessian is PSD by
468        // convention; record that direct certificate instead of routing a
469        // zero-dimensional problem through smoothing-parameter seeding.
470        (
471            Array1::<f64>::zeros(0),
472            0,
473            OuterCriterionCertificate {
474                stationarity:
475                    gam_solve::rho_optimizer::OuterStationarityCertificate::AnalyticGradient {
476                        grad_norm: 0.0,
477                        projected_grad_norm: 0.0,
478                        bound: 0.0,
479                    },
480                hessian_psd: Some(true),
481                lambdas_railed: Vec::new(),
482            },
483        )
484    } else {
485        let mut problem = OuterProblem::new(n_outer)
486            .with_gradient(Derivative::Analytic)
487            .with_hessian(DeclaredHessianForm::Dense)
488            .with_disable_fixed_point(true)
489            .with_continuation_prewarm(false)
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] = rho[active_index].exp();
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        if rho.iter().any(|value| !value.is_finite()) {
722            return Err(invalid("log-lambdas must be finite"));
723        }
724        match &self.statistics {
725            SufficientStatistics::Isotropic { gram, cross } => {
726                self.evaluate_isotropic(rho, gram, cross)
727            }
728            SufficientStatistics::Fisher { gram, cross } => self.evaluate_fisher(rho, gram, cross),
729        }
730    }
731
732    fn evaluate_isotropic(
733        &self,
734        rho: &Array1<f64>,
735        gram: &Array2<f64>,
736        cross: &Array2<f64>,
737    ) -> Result<Evaluation, EstimationError> {
738        let d = self.n_outputs;
739        let (penalty, lambdas) = self.combined_penalty(rho)?;
740        let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
741        let mut penalized = gram.clone();
742        penalized += &penalty;
743        let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
744        let coefficients = inverse.dot(cross);
745        let profiled_deviance = self.profiled_deviance(&coefficients)?;
746        let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
747        validate_profiled_deviance(profiled_deviance)?;
748
749        let m = self.penalties.len();
750        let mut penalty_traces = Array1::<f64>::zeros(m);
751        let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
752        let mut deviance_first = Array1::<f64>::zeros(m);
753        let mut penalty_beta = Vec::with_capacity(m);
754        for (index, penalty_block) in self.penalties.iter().enumerate() {
755            penalty_traces[index] =
756                d as f64 * trace_local_base(&inverse, penalty_block, lambdas[index]);
757            penalty_logdet_traces[index] = d as f64
758                * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
759            let z = apply_local_base_matrix(penalty_block, lambdas[index], &coefficients);
760            deviance_first[index] = sum_products(&coefficients, &z);
761            penalty_beta.push(z);
762        }
763
764        let mut gradient = Array1::<f64>::zeros(m);
765        for j in 0..m {
766            gradient[j] = 0.5
767                * (penalty_traces[j] - penalty_logdet_traces[j]
768                    + residual_degrees_of_freedom * deviance_first[j] / profiled_deviance);
769        }
770        let mut hessian = Array2::<f64>::zeros((m, m));
771        for j in 0..m {
772            let h_sandwich = sandwich_local_base(&inverse, &self.penalties[j], lambdas[j]);
773            let p_sandwich =
774                sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
775            for kk in 0..=j {
776                let h_cross = d as f64
777                    * trace_sandwich_local_base(&h_sandwich, &self.penalties[kk], lambdas[kk]);
778                let p_cross = d as f64
779                    * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
780                let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
781                let deviance_cross = sum_products(&penalty_beta[j], &solved_penalty_beta);
782                let delta = usize::from(j == kk) as f64;
783                let logdet_second = delta * penalty_traces[j] - h_cross;
784                let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
785                let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
786                let value = 0.5
787                    * (logdet_second - penalty_logdet_second
788                        + residual_degrees_of_freedom
789                            * (deviance_second / profiled_deviance
790                                - deviance_first[j] * deviance_first[kk]
791                                    / (profiled_deviance * profiled_deviance)));
792                hessian[[j, kk]] = value;
793                hessian[[kk, j]] = value;
794            }
795        }
796        let cost = 0.5
797            * (d as f64 * log_determinant - d as f64 * spectrum.log_pseudo_determinant
798                + residual_degrees_of_freedom
799                    * (1.0
800                        + (2.0 * std::f64::consts::PI * profiled_deviance
801                            / residual_degrees_of_freedom)
802                            .ln()));
803        validate_evaluation(cost, &gradient, &hessian)?;
804        Ok(Evaluation {
805            cost,
806            gradient,
807            hessian,
808            coefficients,
809            profiled_deviance,
810            penalty_traces,
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        })
906    }
907
908    /// Evaluate the fitted weighted residual quadratic directly from row
909    /// chunks.  Forming it as `y'Wy - (X'Wy)' beta` catastrophically cancels
910    /// on near-interpolating fits; the resulting few ulps are large relative to
911    /// the residual itself and can move a flat REML optimum by many nats under
912    /// an otherwise harmless rotation of the tangent frame.
913    fn profiled_deviance(&self, coefficients: &Array2<f64>) -> Result<f64, EstimationError> {
914        let n = self.design.nrows();
915        let k = self.design.ncols();
916        let d = self.response.ncols();
917        if coefficients.dim() != (k, d) {
918            return Err(invalid(format!(
919                "shared-tangent coefficient shape {:?} does not match ({k}, {d})",
920                coefficients.dim()
921            )));
922        }
923        let mut quadratic = KahanSum::default();
924        let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
925        for start in (0..n).step_by(chunk_rows) {
926            let end = (start + chunk_rows).min(n);
927            let x_chunk = self
928                .design
929                .try_row_chunk(start..end)
930                .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
931            validate_design_chunk(&x_chunk)?;
932            let fitted = x_chunk.dot(coefficients);
933            for local_row in 0..x_chunk.nrows() {
934                let row = start + local_row;
935                let weight = self.weights[row];
936                if weight == 0.0 {
937                    continue;
938                }
939                if let Some(metric) = self.fisher_metric.as_ref() {
940                    for output_a in 0..d {
941                        let residual_a =
942                            self.response[[row, output_a]] - fitted[[local_row, output_a]];
943                        for output_b in 0..d {
944                            let residual_b =
945                                self.response[[row, output_b]] - fitted[[local_row, output_b]];
946                            quadratic.add(
947                                weight
948                                    * residual_a
949                                    * metric[[row, output_a, output_b]]
950                                    * residual_b,
951                            );
952                        }
953                    }
954                } else {
955                    for output in 0..d {
956                        let residual = self.response[[row, output]] - fitted[[local_row, output]];
957                        quadratic.add(weight * residual * residual);
958                    }
959                }
960            }
961        }
962        Ok(quadratic.sum())
963    }
964
965    fn combined_penalty(
966        &self,
967        rho: &Array1<f64>,
968    ) -> Result<(Array2<f64>, Array1<f64>), EstimationError> {
969        let mut combined = Array2::<f64>::zeros((self.n_coefficients, self.n_coefficients));
970        let mut lambdas = Array1::<f64>::zeros(rho.len());
971        for (index, penalty) in self.penalties.iter().enumerate() {
972            let lambda = rho[index].exp();
973            if !lambda.is_finite() || lambda <= 0.0 {
974                return Err(invalid(format!(
975                    "log-lambda {} exponentiated to invalid value {lambda}",
976                    rho[index]
977                )));
978            }
979            lambdas[index] = lambda;
980            for local_row in 0..penalty.local.nrows() {
981                for local_col in 0..penalty.local.ncols() {
982                    combined[[
983                        penalty.column_start + local_row,
984                        penalty.column_start + local_col,
985                    ]] += lambda * penalty.local[[local_row, local_col]];
986                }
987            }
988        }
989        Ok((combined, lambdas))
990    }
991
992    fn residual_degrees_of_freedom(
993        &self,
994        combined_penalty_rank: usize,
995    ) -> Result<f64, EstimationError> {
996        let effective_rows = self
997            .effective_observations
998            .checked_mul(self.n_outputs)
999            .ok_or_else(|| invalid("effective joint row count overflow"))?;
1000        let base_nullity = self
1001            .n_coefficients
1002            .checked_sub(combined_penalty_rank)
1003            .ok_or_else(|| invalid("combined penalty rank exceeds coefficient dimension"))?;
1004        let joint_nullity = base_nullity
1005            .checked_mul(self.n_outputs)
1006            .ok_or_else(|| invalid("joint penalty nullity overflow"))?;
1007        if effective_rows <= joint_nullity {
1008            return Err(invalid(format!(
1009                "REML requires more effective joint rows than unpenalized coefficients; got {effective_rows} rows and nullity {joint_nullity}"
1010            )));
1011        }
1012        Ok((effective_rows - joint_nullity) as f64)
1013    }
1014}
1015
1016fn prepare_penalties(
1017    penalties: &[SharedTangentPenalty],
1018    n_coefficients: usize,
1019) -> Result<Vec<PreparedPenalty>, EstimationError> {
1020    let mut prepared = Vec::with_capacity(penalties.len());
1021    for (slot, penalty) in penalties.iter().enumerate() {
1022        let q = penalty.matrix.nrows();
1023        if q != penalty.matrix.ncols() {
1024            return Err(invalid(format!(
1025                "penalty {slot} must be square; got {}x{}",
1026                penalty.matrix.nrows(),
1027                penalty.matrix.ncols()
1028            )));
1029        }
1030        let end = penalty
1031            .column_start
1032            .checked_add(q)
1033            .ok_or_else(|| invalid(format!("penalty {slot} column range overflow")))?;
1034        if end > n_coefficients {
1035            return Err(invalid(format!(
1036                "penalty {slot} column range {}..{end} exceeds design width {n_coefficients}",
1037                penalty.column_start
1038            )));
1039        }
1040        if penalty.matrix.iter().any(|value| !value.is_finite()) {
1041            return Err(invalid(format!(
1042                "penalty {slot} contains non-finite values"
1043            )));
1044        }
1045        let local = symmetric_average(&penalty.matrix);
1046        let spectrum = penalty_spectrum(&local, &format!("shared-tangent penalty {slot}"))?;
1047        if spectrum.rank == 0 {
1048            continue;
1049        }
1050        prepared.push(PreparedPenalty {
1051            output_slot: slot,
1052            column_start: penalty.column_start,
1053            local,
1054            rank: spectrum.rank,
1055        });
1056    }
1057    Ok(prepared)
1058}
1059
1060fn assemble_isotropic_statistics(
1061    design: &DesignMatrix,
1062    response: &Array2<f64>,
1063    weights: &Array1<f64>,
1064) -> Result<SufficientStatistics, EstimationError> {
1065    let n = design.nrows();
1066    let k = design.ncols();
1067    let d = response.ncols();
1068    let mut gram = Array2::<f64>::zeros((k, k));
1069    let mut cross = Array2::<f64>::zeros((k, d));
1070    let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1071    for start in (0..n).step_by(chunk_rows) {
1072        let end = (start + chunk_rows).min(n);
1073        let x_chunk = design
1074            .try_row_chunk(start..end)
1075            .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1076        validate_design_chunk(&x_chunk)?;
1077        let weight_chunk = weights.slice(s![start..end]);
1078        let response_chunk = response.slice(s![start..end, ..]);
1079        gram += &fast_xt_diag_x(&x_chunk, &weight_chunk);
1080        cross += &fast_xt_diag_y(&x_chunk, &weight_chunk, &response_chunk);
1081    }
1082    Ok(SufficientStatistics::Isotropic { gram, cross })
1083}
1084
1085fn assemble_fisher_statistics(
1086    design: &DesignMatrix,
1087    response: &Array2<f64>,
1088    weights: &Array1<f64>,
1089    fisher_metric: &Array3<f64>,
1090) -> Result<SufficientStatistics, EstimationError> {
1091    let n = design.nrows();
1092    let k = design.ncols();
1093    let d = response.ncols();
1094    let q = k
1095        .checked_mul(d)
1096        .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
1097    let mut gram = Array2::<f64>::zeros((q, q));
1098    let mut cross = Array1::<f64>::zeros(q);
1099    let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1100    for start in (0..n).step_by(chunk_rows) {
1101        let end = (start + chunk_rows).min(n);
1102        let x_chunk = design
1103            .try_row_chunk(start..end)
1104            .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1105        validate_design_chunk(&x_chunk)?;
1106        for local_row in 0..x_chunk.nrows() {
1107            let row = start + local_row;
1108            let metric = fisher_metric.slice(s![row, .., ..]);
1109            let y = response.row(row);
1110            let metric_y = metric.dot(&y);
1111            let weight = weights[row];
1112            for basis_a in 0..k {
1113                let x_a = x_chunk[[local_row, basis_a]];
1114                for output in 0..d {
1115                    cross[basis_a * d + output] += weight * x_a * metric_y[output];
1116                }
1117                for basis_b in 0..k {
1118                    let scale = weight * x_a * x_chunk[[local_row, basis_b]];
1119                    if scale == 0.0 {
1120                        continue;
1121                    }
1122                    for output_a in 0..d {
1123                        for output_b in 0..d {
1124                            gram[[basis_a * d + output_a, basis_b * d + output_b]] +=
1125                                scale * metric[[output_a, output_b]];
1126                        }
1127                    }
1128                }
1129            }
1130        }
1131    }
1132    Ok(SufficientStatistics::Fisher { gram, cross })
1133}
1134
1135fn validated_metric(mut metric: Array2<f64>, row: usize) -> Result<Array2<f64>, EstimationError> {
1136    if metric.iter().any(|value| !value.is_finite()) {
1137        return Err(invalid(format!(
1138            "fisher_metric row {row} contains non-finite values"
1139        )));
1140    }
1141    let scale = metric
1142        .iter()
1143        .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1144    let tolerance = f64::EPSILON.sqrt() * metric.nrows().max(1) as f64 * scale;
1145    for a in 0..metric.nrows() {
1146        for b in (a + 1)..metric.ncols() {
1147            if (metric[[a, b]] - metric[[b, a]]).abs() > tolerance {
1148                return Err(invalid(format!(
1149                    "fisher_metric row {row} is not symmetric at ({a}, {b})"
1150                )));
1151            }
1152            let average = 0.5 * (metric[[a, b]] + metric[[b, a]]);
1153            metric[[a, b]] = average;
1154            metric[[b, a]] = average;
1155        }
1156    }
1157    metric.cholesky(Side::Lower).map_err(|error| {
1158        invalid(format!(
1159            "fisher_metric row {row} must be positive definite: {error}"
1160        ))
1161    })?;
1162    Ok(metric)
1163}
1164
1165fn penalty_spectrum(
1166    penalty: &Array2<f64>,
1167    context: &str,
1168) -> Result<PenaltySpectrum, EstimationError> {
1169    let (eigenvalues, eigenvectors) = penalty
1170        .eigh(Side::Lower)
1171        .map_err(EstimationError::EigendecompositionFailed)?;
1172    let scale = eigenvalues
1173        .iter()
1174        .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1175    let tolerance = f64::EPSILON.sqrt() * eigenvalues.len().max(1) as f64 * scale;
1176    let mut rank = 0usize;
1177    let mut log_pseudo_determinant = 0.0;
1178    let mut pseudo_inverse = Array2::<f64>::zeros(penalty.dim());
1179    for (index, &value) in eigenvalues.iter().enumerate() {
1180        if !value.is_finite() {
1181            return Err(EstimationError::PenaltySpectrumNonFinite {
1182                context: context.to_string(),
1183                index,
1184                value,
1185            });
1186        }
1187        if value < -tolerance {
1188            return Err(EstimationError::PenaltySpectrumIndefinite {
1189                context: context.to_string(),
1190                index,
1191                value,
1192                tolerance,
1193                scale,
1194            });
1195        }
1196        if value <= tolerance {
1197            continue;
1198        }
1199        rank += 1;
1200        log_pseudo_determinant += value.ln();
1201        for row in 0..penalty.nrows() {
1202            for col in 0..penalty.ncols() {
1203                pseudo_inverse[[row, col]] +=
1204                    eigenvectors[[row, index]] * eigenvectors[[col, index]] / value;
1205            }
1206        }
1207    }
1208    Ok(PenaltySpectrum {
1209        rank,
1210        log_pseudo_determinant,
1211        pseudo_inverse,
1212    })
1213}
1214
1215fn spd_inverse_and_logdet(matrix: &Array2<f64>) -> Result<(Array2<f64>, f64), EstimationError> {
1216    let factor =
1217        matrix
1218            .cholesky(Side::Lower)
1219            .map_err(|_| EstimationError::ModelIsIllConditioned {
1220                condition_number: f64::INFINITY,
1221            })?;
1222    let diagonal = factor.diag();
1223    if diagonal
1224        .iter()
1225        .any(|value| !value.is_finite() || *value <= 0.0)
1226    {
1227        return Err(EstimationError::ModelIsIllConditioned {
1228            condition_number: f64::INFINITY,
1229        });
1230    }
1231    let log_determinant = 2.0 * diagonal.iter().map(|value| value.ln()).sum::<f64>();
1232    let identity = Array2::<f64>::eye(matrix.nrows());
1233    let inverse = factor.solve_mat(&identity);
1234    if !log_determinant.is_finite() || inverse.iter().any(|value| !value.is_finite()) {
1235        return Err(EstimationError::ModelIsIllConditioned {
1236            condition_number: f64::INFINITY,
1237        });
1238    }
1239    Ok((inverse, log_determinant))
1240}
1241
1242fn trace_local_base(inverse: &Array2<f64>, penalty: &PreparedPenalty, lambda: f64) -> f64 {
1243    let mut trace = 0.0;
1244    for row in 0..penalty.local.nrows() {
1245        for col in 0..penalty.local.ncols() {
1246            trace += lambda
1247                * penalty.local[[row, col]]
1248                * inverse[[penalty.column_start + col, penalty.column_start + row]];
1249        }
1250    }
1251    trace
1252}
1253
1254fn trace_local_joint(
1255    inverse: &Array2<f64>,
1256    penalty: &PreparedPenalty,
1257    lambda: f64,
1258    n_outputs: usize,
1259) -> f64 {
1260    let mut trace = 0.0;
1261    for output in 0..n_outputs {
1262        for row in 0..penalty.local.nrows() {
1263            for col in 0..penalty.local.ncols() {
1264                trace += lambda
1265                    * penalty.local[[row, col]]
1266                    * inverse[[
1267                        (penalty.column_start + col) * n_outputs + output,
1268                        (penalty.column_start + row) * n_outputs + output,
1269                    ]];
1270            }
1271        }
1272    }
1273    trace
1274}
1275
1276fn apply_local_base_matrix(
1277    penalty: &PreparedPenalty,
1278    lambda: f64,
1279    matrix: &Array2<f64>,
1280) -> Array2<f64> {
1281    let mut output = Array2::<f64>::zeros(matrix.dim());
1282    for row in 0..penalty.local.nrows() {
1283        for col in 0..penalty.local.ncols() {
1284            let value = lambda * penalty.local[[row, col]];
1285            for output_index in 0..matrix.ncols() {
1286                output[[penalty.column_start + row, output_index]] +=
1287                    value * matrix[[penalty.column_start + col, output_index]];
1288            }
1289        }
1290    }
1291    output
1292}
1293
1294fn apply_local_joint_vector(
1295    penalty: &PreparedPenalty,
1296    lambda: f64,
1297    n_outputs: usize,
1298    vector: &Array1<f64>,
1299) -> Array1<f64> {
1300    let mut output = Array1::<f64>::zeros(vector.len());
1301    for output_index in 0..n_outputs {
1302        for row in 0..penalty.local.nrows() {
1303            for col in 0..penalty.local.ncols() {
1304                output[(penalty.column_start + row) * n_outputs + output_index] += lambda
1305                    * penalty.local[[row, col]]
1306                    * vector[(penalty.column_start + col) * n_outputs + output_index];
1307            }
1308        }
1309    }
1310    output
1311}
1312
1313fn sandwich_local_base(
1314    inverse: &Array2<f64>,
1315    penalty: &PreparedPenalty,
1316    lambda: f64,
1317) -> Array2<f64> {
1318    let dimension = inverse.nrows();
1319    let mut result = Array2::<f64>::zeros((dimension, dimension));
1320    for local_row in 0..penalty.local.nrows() {
1321        let global_row = penalty.column_start + local_row;
1322        for local_col in 0..penalty.local.ncols() {
1323            let value = lambda * penalty.local[[local_row, local_col]];
1324            if value == 0.0 {
1325                continue;
1326            }
1327            let global_col = penalty.column_start + local_col;
1328            for row in 0..dimension {
1329                let left = inverse[[row, global_row]] * value;
1330                for col in 0..dimension {
1331                    result[[row, col]] += left * inverse[[global_col, col]];
1332                }
1333            }
1334        }
1335    }
1336    result
1337}
1338
1339fn sandwich_local_joint(
1340    inverse: &Array2<f64>,
1341    penalty: &PreparedPenalty,
1342    lambda: f64,
1343    n_outputs: usize,
1344) -> Array2<f64> {
1345    let dimension = inverse.nrows();
1346    let mut result = Array2::<f64>::zeros((dimension, dimension));
1347    for output in 0..n_outputs {
1348        for local_row in 0..penalty.local.nrows() {
1349            let global_row = (penalty.column_start + local_row) * n_outputs + output;
1350            for local_col in 0..penalty.local.ncols() {
1351                let value = lambda * penalty.local[[local_row, local_col]];
1352                if value == 0.0 {
1353                    continue;
1354                }
1355                let global_col = (penalty.column_start + local_col) * n_outputs + output;
1356                for row in 0..dimension {
1357                    let left = inverse[[row, global_row]] * value;
1358                    for col in 0..dimension {
1359                        result[[row, col]] += left * inverse[[global_col, col]];
1360                    }
1361                }
1362            }
1363        }
1364    }
1365    result
1366}
1367
1368fn trace_sandwich_local_base(
1369    sandwich: &Array2<f64>,
1370    penalty: &PreparedPenalty,
1371    lambda: f64,
1372) -> f64 {
1373    let mut trace = 0.0;
1374    for row in 0..penalty.local.nrows() {
1375        for col in 0..penalty.local.ncols() {
1376            trace += lambda
1377                * penalty.local[[row, col]]
1378                * sandwich[[penalty.column_start + col, penalty.column_start + row]];
1379        }
1380    }
1381    trace
1382}
1383
1384fn trace_sandwich_local_joint(
1385    sandwich: &Array2<f64>,
1386    penalty: &PreparedPenalty,
1387    lambda: f64,
1388    n_outputs: usize,
1389) -> f64 {
1390    let mut trace = 0.0;
1391    for output in 0..n_outputs {
1392        for row in 0..penalty.local.nrows() {
1393            for col in 0..penalty.local.ncols() {
1394                trace += lambda
1395                    * penalty.local[[row, col]]
1396                    * sandwich[[
1397                        (penalty.column_start + col) * n_outputs + output,
1398                        (penalty.column_start + row) * n_outputs + output,
1399                    ]];
1400            }
1401        }
1402    }
1403    trace
1404}
1405
1406fn add_base_penalty_to_joint(joint: &mut Array2<f64>, penalty: &Array2<f64>, n_outputs: usize) {
1407    for row in 0..penalty.nrows() {
1408        for col in 0..penalty.ncols() {
1409            let value = penalty[[row, col]];
1410            for output in 0..n_outputs {
1411                joint[[row * n_outputs + output, col * n_outputs + output]] += value;
1412            }
1413        }
1414    }
1415}
1416
1417fn symmetric_average(matrix: &Array2<f64>) -> Array2<f64> {
1418    let mut output = matrix.clone();
1419    for row in 0..matrix.nrows() {
1420        for col in (row + 1)..matrix.ncols() {
1421            let average = 0.5 * (matrix[[row, col]] + matrix[[col, row]]);
1422            output[[row, col]] = average;
1423            output[[col, row]] = average;
1424        }
1425    }
1426    output
1427}
1428
1429fn predict_from_coefficients(
1430    design: &DesignMatrix,
1431    coefficients: &Array2<f64>,
1432) -> Result<Array2<f64>, EstimationError> {
1433    if design.ncols() != coefficients.nrows() {
1434        return Err(invalid(format!(
1435            "prediction design width {} does not match coefficient rows {}",
1436            design.ncols(),
1437            coefficients.nrows()
1438        )));
1439    }
1440    let mut prediction = Array2::<f64>::zeros((design.nrows(), coefficients.ncols()));
1441    for output in 0..coefficients.ncols() {
1442        let values = design.apply(&coefficients.column(output).to_owned());
1443        prediction.column_mut(output).assign(&values);
1444    }
1445    if prediction.iter().any(|value| !value.is_finite()) {
1446        return Err(invalid("prediction produced non-finite values"));
1447    }
1448    Ok(prediction)
1449}
1450
1451fn sum_products(left: &Array2<f64>, right: &Array2<f64>) -> f64 {
1452    left.iter()
1453        .zip(right.iter())
1454        .map(|(left, right)| left * right)
1455        .sum()
1456}
1457
1458fn validate_design_chunk(chunk: &Array2<f64>) -> Result<(), EstimationError> {
1459    if chunk.iter().any(|value| !value.is_finite()) {
1460        return Err(invalid("design contains non-finite values"));
1461    }
1462    Ok(())
1463}
1464
1465fn validate_profiled_deviance(value: f64) -> Result<(), EstimationError> {
1466    if !value.is_finite() || value <= 0.0 {
1467        return Err(EstimationError::RemlOptimizationFailed(format!(
1468            "{FIT_CONTEXT}: profiled penalized deviance must be finite and positive, got {value}"
1469        )));
1470    }
1471    Ok(())
1472}
1473
1474fn validate_evaluation(
1475    cost: f64,
1476    gradient: &Array1<f64>,
1477    hessian: &Array2<f64>,
1478) -> Result<(), EstimationError> {
1479    if !cost.is_finite()
1480        || gradient.iter().any(|value| !value.is_finite())
1481        || hessian.iter().any(|value| !value.is_finite())
1482    {
1483        return Err(EstimationError::RemlOptimizationFailed(format!(
1484            "{FIT_CONTEXT}: objective evaluation produced non-finite value or derivatives"
1485        )));
1486    }
1487    Ok(())
1488}
1489
1490fn validate_archived_tangent_fit(
1491    fit: &SharedTangentRemlFit,
1492) -> Result<(), ResponseGeometryModelError> {
1493    if fit.n_observations == 0
1494        || fit.n_outputs == 0
1495        || fit.coefficients.nrows() == 0
1496        || fit.coefficients.ncols() != fit.n_outputs
1497        || fit.fitted.dim() != (fit.n_observations, fit.n_outputs)
1498    {
1499        return Err(ResponseGeometryModelError::InvalidMetadata(
1500            "shared tangent fit has inconsistent dimensions".to_string(),
1501        ));
1502    }
1503    if fit.lambdas.len() != fit.edf_by_penalty.len() {
1504        return Err(ResponseGeometryModelError::InvalidMetadata(
1505            "shared tangent lambda and EDF vectors are misaligned".to_string(),
1506        ));
1507    }
1508    if fit.coefficients.iter().any(|value| !value.is_finite())
1509        || fit.fitted.iter().any(|value| !value.is_finite())
1510        || fit
1511            .lambdas
1512            .iter()
1513            .any(|value| !value.is_finite() || *value < 0.0)
1514        || fit
1515            .edf_by_penalty
1516            .iter()
1517            .any(|value| !value.is_finite() || *value < 0.0)
1518        || !fit.sigma2.is_finite()
1519        || fit.sigma2 <= 0.0
1520        || !fit.edf_total.is_finite()
1521        || fit.edf_total < 0.0
1522        || !fit.reml_score.is_finite()
1523    {
1524        return Err(ResponseGeometryModelError::InvalidMetadata(
1525            "shared tangent fit contains invalid numerical values".to_string(),
1526        ));
1527    }
1528    if !fit.outer_certificate.certifies() {
1529        return Err(ResponseGeometryModelError::InvalidMetadata(
1530            "shared tangent fit lacks a valid convergence certificate".to_string(),
1531        ));
1532    }
1533    Ok(())
1534}
1535
1536fn bounded_roundoff_value(
1537    value: f64,
1538    lower: f64,
1539    upper: f64,
1540    context: &str,
1541) -> Result<f64, EstimationError> {
1542    let tolerance = f64::EPSILON.sqrt() * upper.abs().max(1.0);
1543    if !value.is_finite() || value < lower - tolerance || value > upper + tolerance {
1544        return Err(EstimationError::RemlOptimizationFailed(format!(
1545            "{FIT_CONTEXT}: {context} {value} lies outside [{lower}, {upper}] beyond roundoff"
1546        )));
1547    }
1548    Ok(value.clamp(lower, upper))
1549}
1550
1551fn invalid(message: impl Into<String>) -> EstimationError {
1552    EstimationError::InvalidInput(message.into())
1553}
1554
1555#[cfg(test)]
1556mod tests {
1557    use super::*;
1558    use gam_linalg::test_support::no_densify_design;
1559    use ndarray::{Array3, array};
1560
1561    fn fixture_request(fisher_metric: Option<Array3<f64>>) -> SharedTangentRemlRequest {
1562        let design = array![
1563            [1.0, -1.0, 0.5],
1564            [1.0, -0.5, -0.2],
1565            [1.0, 0.0, 0.3],
1566            [1.0, 0.5, 0.8],
1567            [1.0, 1.0, -0.4],
1568            [1.0, 1.5, 0.1]
1569        ];
1570        let response = array![
1571            [-0.7, 0.4],
1572            [-0.1, 0.1],
1573            [0.2, -0.3],
1574            [0.8, -0.2],
1575            [1.1, 0.5],
1576            [1.7, 0.2]
1577        ];
1578        let penalties = vec![
1579            SharedTangentPenalty::new(1, array![[1.0, 0.0], [0.0, 0.0]]),
1580            SharedTangentPenalty::new(1, array![[0.0, 0.0], [0.0, 1.0]]),
1581        ];
1582        SharedTangentRemlRequest::new(
1583            no_densify_design(design),
1584            response,
1585            array![1.0, 0.8, 1.2, 1.0, 0.9, 1.1],
1586            fisher_metric,
1587            penalties,
1588        )
1589    }
1590
1591    #[test]
1592    fn operator_backed_isotropic_path_matches_streamed_identity_fisher_path() {
1593        let isotropic_request = fixture_request(None);
1594        let n = isotropic_request.response.nrows();
1595        let d = isotropic_request.response.ncols();
1596        let mut identity_metric = Array3::<f64>::zeros((n, d, d));
1597        for row in 0..n {
1598            for output in 0..d {
1599                identity_metric[[row, output, output]] = 1.0;
1600            }
1601        }
1602        let fisher_request = fixture_request(Some(identity_metric));
1603        let isotropic = PreparedSharedTangent::from_request(isotropic_request)
1604            .expect("prepare isotropic without densifying");
1605        let fisher = PreparedSharedTangent::from_request(fisher_request)
1606            .expect("prepare Fisher without densifying");
1607        let rho = array![-0.4, 0.7];
1608        let left = isotropic.evaluate(&rho).expect("isotropic eval");
1609        let right = fisher.evaluate(&rho).expect("Fisher eval");
1610        assert_close(left.cost, right.cost, 2.0e-11);
1611        assert_array1_close(&left.gradient, &right.gradient, 2.0e-10);
1612        assert_array2_close(&left.hessian, &right.hessian, 2.0e-9);
1613        assert_array2_close(&left.coefficients, &right.coefficients, 2.0e-11);
1614    }
1615
1616    #[test]
1617    fn analytic_gradient_and_hessian_match_test_only_finite_differences() {
1618        let request = fixture_request(None);
1619        let prepared = PreparedSharedTangent::from_request(request).expect("prepare");
1620        let rho = array![-0.2, 0.35];
1621        let exact = prepared.evaluate(&rho).expect("exact eval");
1622        let step = f64::EPSILON.cbrt();
1623        for j in 0..rho.len() {
1624            let mut plus = rho.clone();
1625            let mut minus = rho.clone();
1626            plus[j] += step;
1627            minus[j] -= step;
1628            let plus_eval = prepared.evaluate(&plus).expect("plus eval");
1629            let minus_eval = prepared.evaluate(&minus).expect("minus eval");
1630            let gradient_fd = (plus_eval.cost - minus_eval.cost) / (2.0 * step);
1631            assert_close(exact.gradient[j], gradient_fd, 2.0e-6);
1632            for k in 0..rho.len() {
1633                let hessian_fd = (plus_eval.gradient[k] - minus_eval.gradient[k]) / (2.0 * step);
1634                assert_close(exact.hessian[[k, j]], hessian_fd, 3.0e-6);
1635            }
1636        }
1637    }
1638
1639    #[test]
1640    fn streamed_varying_fisher_statistics_match_explicit_joint_oracle() {
1641        let base = fixture_request(None);
1642        let n = base.response.nrows();
1643        let d = base.response.ncols();
1644        let mut metric = Array3::<f64>::zeros((n, d, d));
1645        for row in 0..n {
1646            let off = 0.04 * (row as f64 + 1.0);
1647            metric[[row, 0, 0]] = 1.2 + 0.1 * row as f64;
1648            metric[[row, 0, 1]] = off;
1649            metric[[row, 1, 0]] = off;
1650            metric[[row, 1, 1]] = 0.9 + 0.05 * row as f64;
1651        }
1652        let request = fixture_request(Some(metric.clone()));
1653        let prepared =
1654            PreparedSharedTangent::from_request(request.clone()).expect("prepare Fisher");
1655        let SufficientStatistics::Fisher { gram, cross } = &prepared.statistics else {
1656            panic!("expected Fisher statistics")
1657        };
1658        let x = base.design.try_row_chunk(0..n).expect("test design rows");
1659        let k = x.ncols();
1660        let q = k * d;
1661        let mut oracle_gram = Array2::<f64>::zeros((q, q));
1662        let mut oracle_cross = Array1::<f64>::zeros(q);
1663        let mut oracle_response = 0.0;
1664        for row in 0..n {
1665            for a in 0..k {
1666                for o in 0..d {
1667                    let ao = a * d + o;
1668                    for p in 0..d {
1669                        oracle_cross[ao] += request.weights[row]
1670                            * x[[row, a]]
1671                            * metric[[row, o, p]]
1672                            * request.response[[row, p]];
1673                    }
1674                    for b in 0..k {
1675                        for p in 0..d {
1676                            oracle_gram[[ao, b * d + p]] += request.weights[row]
1677                                * x[[row, a]]
1678                                * x[[row, b]]
1679                                * metric[[row, o, p]];
1680                        }
1681                    }
1682                }
1683            }
1684            let y = request.response.row(row);
1685            oracle_response += request.weights[row] * y.dot(&metric.slice(s![row, .., ..]).dot(&y));
1686        }
1687        assert_array2_close(gram, &oracle_gram, 2.0e-12);
1688        assert_array1_close(cross, &oracle_cross, 2.0e-12);
1689        let zero_coefficients = Array2::<f64>::zeros((k, d));
1690        let direct_response_quadratic = prepared
1691            .profiled_deviance(&zero_coefficients)
1692            .expect("direct zero-fit quadratic");
1693        assert_close(direct_response_quadratic, oracle_response, 2.0e-12);
1694    }
1695
1696    #[test]
1697    fn parametric_fit_is_certified_serializable_and_predicts_in_core() {
1698        let design = array![[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
1699        let response = array![[0.2, -0.1], [0.9, 0.4], [2.1, 0.8], [2.8, 1.4]];
1700        let request = SharedTangentRemlRequest::from_dense(
1701            design.clone(),
1702            response,
1703            Array1::ones(4),
1704            None,
1705            Vec::new(),
1706        );
1707        let fit = fit_shared_tangent_reml(request).expect("certified parametric fit");
1708        assert!(fit.outer_certificate.certifies());
1709        let prediction = fit.predict_dense(design).expect("core prediction");
1710        assert_array2_close(&prediction, &fit.fitted, 1.0e-12);
1711        let encoded = serde_json::to_string(&fit).expect("serialize fit");
1712        let decoded: SharedTangentRemlFit =
1713            serde_json::from_str(&encoded).expect("deserialize fit");
1714        assert_array2_close(&decoded.coefficients, &fit.coefficients, 0.0);
1715        assert!(decoded.outer_certificate.certifies());
1716    }
1717
1718    fn assert_close(left: f64, right: f64, tolerance: f64) {
1719        let scale = left.abs().max(right.abs()).max(1.0);
1720        assert!(
1721            (left - right).abs() <= tolerance * scale,
1722            "{left} != {right} within relative tolerance {tolerance}"
1723        );
1724    }
1725
1726    fn assert_array1_close(left: &Array1<f64>, right: &Array1<f64>, tolerance: f64) {
1727        assert_eq!(left.len(), right.len());
1728        for (left, right) in left.iter().zip(right.iter()) {
1729            assert_close(*left, *right, tolerance);
1730        }
1731    }
1732
1733    fn assert_array2_close(left: &Array2<f64>, right: &Array2<f64>, tolerance: f64) {
1734        assert_eq!(left.dim(), right.dim());
1735        for (left, right) in left.iter().zip(right.iter()) {
1736            assert_close(*left, *right, tolerance);
1737        }
1738    }
1739}