Skip to main content

fdars_core/
peer.rs

1//! Structured-penalty scalar-on-function regression via PEER.
2//!
3//! PEER (Partially Empirical Eigenvectors for Regression) estimates the
4//! coefficient function β(t) via penalized normal equations
5//! `(W_c'W_c + λQ)β = W_c'y_c`, where `W_c` is the centered, integration-
6//! weighted design matrix and `Q` is a penalty chosen from three families.
7//!
8//! # Penalty families
9//!
10//! - [`PeerPenalty::Ridge`] — identity penalty (uniform shrinkage).
11//! - [`PeerPenalty::Difference`] — second-difference roughness (D'D, order 2).
12//! - [`PeerPenalty::Decree`] — caller-supplied structured penalty matrix.
13//!
14//! # Quick start
15//!
16//! ```
17//! use fdars_core::matrix::FdMatrix;
18//! use fdars_core::peer::{peer, PeerConfig, PeerPenalty, LambdaChoice};
19//! use fdars_core::helpers::simpsons_weights;
20//!
21//! // Tiny synthetic dataset: n=10 observations, m=5 evaluation points.
22//! let (n, m) = (10_usize, 5_usize);
23//! let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
24//! let mut data = FdMatrix::zeros(n, m);
25//! let mut y = vec![0.0_f64; n];
26//! let true_beta: Vec<f64> = argvals.iter()
27//!     .map(|&t| (std::f64::consts::PI * t).sin()).collect();
28//! let w = simpsons_weights(&argvals);
29//! for i in 0..n {
30//!     for j in 0..m {
31//!         let xi = ((i * m + j) as f64 * 0.3).sin();
32//!         data[(i, j)] = xi;
33//!         y[i] += xi * true_beta[j] * w[j];
34//!     }
35//! }
36//! // Fit PEER with Ridge penalty and a fixed λ.
37//! let mut config = PeerConfig::default();
38//! config.penalty = PeerPenalty::Ridge;
39//! config.lambda = LambdaChoice::Fixed(1e-3);
40//! let fit = peer(&data, &y, &argvals, &config).unwrap();
41//! assert_eq!(fit.beta.len(), m);
42//! assert!(fit.fitted_values.iter().all(|v| v.is_finite()));
43//! // Predict on training data — must reproduce fitted_values (self-consistency).
44//! let preds = fit.predict(&data, &argvals).unwrap();
45//! for (p, f) in preds.iter().zip(&fit.fitted_values) {
46//!     assert!((p - f).abs() < 1e-9);
47//! }
48//! ```
49
50use crate::error::FdarError;
51use crate::function_on_scalar::penalty_matrix;
52use crate::helpers::simpsons_weights;
53use crate::linalg::{cholesky_factor, cholesky_forward_back, cholesky_solve};
54use crate::matrix::FdMatrix;
55use nalgebra::DMatrix;
56
57// ---------------------------------------------------------------------------
58// Public types
59// ---------------------------------------------------------------------------
60
61/// Penalty family for the PEER estimator.
62///
63/// Selects the penalty matrix Q (m×m) entering the penalized normal equations
64/// `(W_c'W_c + λQ)β = W_c'y_c`.
65#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub enum PeerPenalty {
69    /// Identity penalty Q = I_m (uniform ridge shrinkage).
70    Ridge,
71    /// Second-difference roughness penalty Q = D'D (only `order = 2` is
72    /// supported in this release; other orders return
73    /// [`FdarError::InvalidParameter`]).
74    Difference { order: usize },
75    /// Caller-supplied penalty matrix.
76    ///
77    /// The tuple holds `(q_flat, p)` where `q_flat` is a flat row-major m×m
78    /// symmetric PSD matrix (length `p*p`) and `p` is its dimension. `p` must
79    /// equal the number of argvals grid points `m`; a mismatch returns
80    /// [`FdarError::InvalidDimension`].
81    ///
82    /// Q must be symmetric (row-major and column-major are equivalent for
83    /// symmetric matrices) and positive semi-definite for Cholesky stability.
84    Decree(Vec<f64>, usize),
85}
86
87impl Default for PeerPenalty {
88    fn default() -> Self {
89        PeerPenalty::Difference { order: 2 }
90    }
91}
92
93/// How to choose the smoothing parameter λ for the PEER estimator.
94///
95/// The default is [`LambdaChoice::Gcv`] which automatically selects λ by
96/// minimising the GCV score over a fixed internal log-spaced grid.
97#[non_exhaustive]
98#[derive(Debug, Clone, Default, PartialEq)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100pub enum LambdaChoice {
101    /// Use this value verbatim; no grid search or EM is performed.
102    Fixed(f64),
103    /// Select λ by minimising the GCV score on a fixed 40-point log-spaced
104    /// internal grid over [1e-6, 1e4].  Fully deterministic; ties resolve to
105    /// the smaller grid index.
106    #[default]
107    Gcv,
108    /// Estimate λ via a self-contained REML EM using the eigendecomposition of
109    /// the penalty matrix Q (null space → fixed effect; range space → random
110    /// effect b ∼ N(0, σ²_u I)).  Returns λ = σ²_e / σ²_u.  Fully
111    /// deterministic; fixed initialisation and 100-iteration cap.
112    Reml,
113}
114
115/// Records which λ-selection path actually ran.
116#[non_exhaustive]
117#[derive(Debug, Clone, PartialEq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub enum LambdaMethod {
120    /// A fixed value was supplied via [`LambdaChoice::Fixed`]; no search ran.
121    Fixed,
122    /// λ was selected by GCV grid search.
123    Gcv,
124    /// λ was estimated by the self-contained REML EM.
125    Reml,
126}
127
128/// Configuration for the [`peer`] estimator.
129///
130/// Construct via `PeerConfig::default()`, then assign the fields you need (e.g. `let mut c = PeerConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
131#[non_exhaustive]
132#[derive(Debug, Clone, Default, PartialEq)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct PeerConfig {
135    /// Penalty family selecting the structured Q matrix.
136    pub penalty: PeerPenalty,
137    /// How to choose (or fix) the smoothing parameter λ.
138    ///
139    /// - [`LambdaChoice::Fixed(v)`](LambdaChoice::Fixed) — use `v` verbatim.
140    /// - [`LambdaChoice::Gcv`] — automatic GCV grid search (default).
141    /// - [`LambdaChoice::Reml`] — automatic REML EM estimation.
142    pub lambda: LambdaChoice,
143}
144
145/// Result of the [`peer`] estimator.
146///
147/// Carries the estimated coefficient function β(t), model diagnostics, and
148/// the penalty configuration used — all on the `argvals` grid.
149#[derive(Debug, Clone, PartialEq)]
150#[non_exhaustive]
151#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
152#[must_use = "expensive computation whose result should not be discarded"]
153pub struct PeerResult {
154    /// Estimated coefficient function β(t), length m (on the argvals grid).
155    pub beta: Vec<f64>,
156    /// Intercept (= ȳ, the mean of the response vector).
157    ///
158    /// This is the centered-response mean, not the out-of-sample prediction
159    /// intercept. Prediction on a new curve x* uses
160    /// `ȳ + Σ_j (x*[j]·w[j] − w_bar[j])·β[j] = (ȳ − w_bar·β) + Σ_j x*[j]·w[j]·β[j]`,
161    /// so a predictor must combine `intercept` with [`w_bar`](Self::w_bar) and
162    /// `beta`. Storing `w_bar` here keeps that reconstruction exact (consumed by
163    /// out-of-sample prediction in a later phase).
164    pub intercept: f64,
165    /// Column means of the Simpson-weighted design `W[i,j] = data[(i,j)]·w[j]`,
166    /// length m. Retained so out-of-sample prediction can reproduce the same
167    /// centering the fit used (see [`intercept`](Self::intercept)).
168    pub w_bar: Vec<f64>,
169    /// Fitted values ŷ_i, length n.
170    pub fitted_values: Vec<f64>,
171    /// Effective degrees of freedom tr(H) = tr((W_c'W_c + λQ)^{-1} W_c'W_c).
172    pub effective_df: f64,
173    /// Smoothing parameter λ that was used.
174    pub lambda: f64,
175    /// Penalty family that was used.
176    pub penalty_type: PeerPenalty,
177    /// GCV score at the selected λ.  `Some(score)` when [`LambdaMethod::Gcv`]
178    /// ran; `None` when [`LambdaMethod::Fixed`] or [`LambdaMethod::Reml`].
179    pub gcv: Option<f64>,
180    /// Which λ-selection path ran.
181    pub lambda_method: LambdaMethod,
182}
183
184/// Result of the [`lpeer`] longitudinal PEER estimator.
185///
186/// Carries the estimated coefficient function β(t), subject-level variance
187/// components, and the penalty configuration used — all on the `argvals` grid.
188///
189/// Both variance components are non-negative (clamped to a positive floor by
190/// `famm::fit_scalar_mixed_model`).
191#[derive(Debug, Clone, PartialEq)]
192#[non_exhaustive]
193#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
194#[must_use = "expensive computation whose result should not be discarded"]
195pub struct LocalPeerResult {
196    /// Estimated coefficient function β(t), length m (on the argvals grid).
197    pub beta: Vec<f64>,
198    /// Intercept (= ȳ, the mean of the response vector). Matches `peer()` convention.
199    pub intercept: f64,
200    /// Column means of the Simpson-weighted design `W[i,j] = data[(i,j)]·w[j]`,
201    /// length m. Computed identically to `peer()` so [`predict`](Self::predict)
202    /// reproduces training `fitted_values` exactly.
203    pub w_bar: Vec<f64>,
204    /// Fitted values ŷ_i (marginal, fixed-effect only), length n.
205    pub fitted_values: Vec<f64>,
206    /// Between-subject variance σ²_u (≥ 0). Estimated by REML EM inside
207    /// `famm::fit_scalar_mixed_model`; clamped to a positive floor.
208    pub sigma2_subject: f64,
209    /// Residual variance σ²_ε (≥ 0). Estimated by REML EM; clamped to a positive floor.
210    pub sigma2_resid: f64,
211    /// Number of unique subjects derived from `subject_map`.
212    pub n_subjects: usize,
213    /// Smoothing parameter λ that was used (from `PeerConfig`).
214    pub lambda: f64,
215    /// Penalty family that was used.
216    pub penalty_type: PeerPenalty,
217    /// GCV score at the selected λ. `Some(score)` when [`LambdaMethod::Gcv`]
218    /// ran; `None` for [`LambdaMethod::Fixed`] or [`LambdaMethod::Reml`].
219    pub gcv: Option<f64>,
220    /// Which λ-selection path ran.
221    pub lambda_method: LambdaMethod,
222}
223
224// ---------------------------------------------------------------------------
225// Public entry point
226// ---------------------------------------------------------------------------
227
228/// Fit the PEER scalar-on-function regression model.
229///
230/// Estimates the coefficient function β(t) on the `argvals` grid by solving
231/// the penalized normal equations `(W_c'W_c + λQ)β = W_c'y_c`, where
232/// `W_c[i,j] = (data[(i,j)] · w[j]) − column_mean` and `w` are Simpson's
233/// integration weights.
234///
235/// # Arguments
236///
237/// * `data`    — n×m functional predictor matrix (rows = observations,
238///   columns = evaluation points; column-major [`FdMatrix`]).
239/// * `y`       — scalar response vector, length n.
240/// * `argvals` — evaluation grid, length m (must equal `data.ncols()`).
241/// * `config`  — penalty family and λ selection.
242///
243/// # Errors
244///
245/// Returns [`FdarError::InvalidDimension`] when dimensions are inconsistent
246/// or a Decree Q has wrong size, [`FdarError::InvalidParameter`] for
247/// unsupported `Difference` orders, and [`FdarError::ComputationFailed`]
248/// when the penalized system is numerically singular.
249pub fn peer(
250    data: &FdMatrix,
251    y: &[f64],
252    argvals: &[f64],
253    config: &PeerConfig,
254) -> Result<PeerResult, FdarError> {
255    let (n, m) = data.shape();
256
257    // --- Entry validation ---
258    if n < 2 {
259        // Centering collapses a single observation to β = 0 (all-zero design);
260        // require at least 2 so the fit is not silently degenerate.
261        return Err(FdarError::InvalidDimension {
262            parameter: "data",
263            expected: "at least 2 observations".to_string(),
264            actual: format!("{n} rows"),
265        });
266    }
267    if m < 3 {
268        return Err(FdarError::InvalidDimension {
269            parameter: "data",
270            expected: "at least 3 evaluation points (m >= 3)".to_string(),
271            actual: format!("{m} columns"),
272        });
273    }
274    if argvals.len() != m {
275        return Err(FdarError::InvalidDimension {
276            parameter: "argvals",
277            expected: format!("{m}"),
278            actual: format!("{}", argvals.len()),
279        });
280    }
281    if y.len() != n {
282        return Err(FdarError::InvalidDimension {
283            parameter: "y",
284            expected: format!("{n}"),
285            actual: format!("{}", y.len()),
286        });
287    }
288    // Non-finite inputs would propagate into finite-but-wrong normal equations,
289    // bypassing the post-solve β NaN guard — reject them explicitly.
290    if y.iter().any(|v| !v.is_finite()) {
291        return Err(FdarError::InvalidParameter {
292            parameter: "y",
293            message: "response contains non-finite values (NaN/Inf)".to_string(),
294        });
295    }
296    if argvals.iter().any(|v| !v.is_finite()) {
297        return Err(FdarError::InvalidParameter {
298            parameter: "argvals",
299            message: "argvals contains non-finite values (NaN/Inf)".to_string(),
300        });
301    }
302    // Simpson's weights assume a strictly increasing grid; a non-monotone grid
303    // yields negative weights that silently corrupt the design integral.
304    if argvals.windows(2).any(|w| w[1] <= w[0]) {
305        return Err(FdarError::InvalidParameter {
306            parameter: "argvals",
307            message: "argvals must be strictly increasing".to_string(),
308        });
309    }
310
311    // 1. Integration weights w[j]
312    let w = simpsons_weights(argvals);
313
314    // 2. Weighted design: wmat[i,j] = data[i,j] * w[j]
315    let mut wmat = FdMatrix::zeros(n, m);
316    for i in 0..n {
317        for j in 0..m {
318            wmat[(i, j)] = data[(i, j)] * w[j];
319        }
320    }
321
322    // 3. Center response and design
323    let y_bar: f64 = y.iter().sum::<f64>() / n as f64;
324    let yc: Vec<f64> = y.iter().map(|&yi| yi - y_bar).collect();
325
326    let w_bar: Vec<f64> = (0..m)
327        .map(|j| (0..n).map(|i| wmat[(i, j)]).sum::<f64>() / n as f64)
328        .collect();
329
330    let mut wc = FdMatrix::zeros(n, m);
331    for i in 0..n {
332        for j in 0..m {
333            wc[(i, j)] = wmat[(i, j)] - w_bar[j];
334        }
335    }
336
337    // 4. Build penalty Q (m×m, row-major)
338    let q = build_q(m, &config.penalty)?;
339
340    // 5. WtW (m×m, row-major, symmetric) and wty (length m)
341    let mut wtw = vec![0.0_f64; m * m];
342    for j in 0..m {
343        for k in j..m {
344            let s: f64 = (0..n).map(|i| wc[(i, j)] * wc[(i, k)]).sum();
345            wtw[j * m + k] = s;
346            wtw[k * m + j] = s;
347        }
348    }
349    let wty: Vec<f64> = (0..m)
350        .map(|j| (0..n).map(|i| wc[(i, j)] * yc[i]).sum())
351        .collect();
352
353    // 6. λ selection — dispatch based on config.lambda
354    let (lambda, gcv_score, lambda_method) = match &config.lambda {
355        LambdaChoice::Fixed(lam) => (*lam, None, LambdaMethod::Fixed),
356        LambdaChoice::Gcv => {
357            let (lam, g) = select_lambda_gcv_peer(&wc, &yc, &wtw, &wty, &q, m, n);
358            (lam, Some(g), LambdaMethod::Gcv)
359        }
360        LambdaChoice::Reml => {
361            let lam = select_lambda_reml_peer(&wc, &yc, &q, m, n);
362            (lam, None, LambdaMethod::Reml)
363        }
364    };
365
366    // 7. A = WtW + λQ; solve A β = wty via Cholesky
367    let mut a = vec![0.0_f64; m * m];
368    for i in 0..m * m {
369        a[i] = wtw[i] + lambda * q[i];
370    }
371    let beta = cholesky_solve(&a, &wty, m)?;
372
373    // NaN guard: non-finite β should not be returned silently
374    if beta.iter().any(|v| !v.is_finite()) {
375        return Err(FdarError::ComputationFailed {
376            operation: "peer",
377            detail: "non-finite coefficient (singular penalized system)".into(),
378        });
379    }
380
381    // 8. Effective degrees of freedom: tr(H) = tr(A^{-1} WtW)
382    let effective_df = compute_peer_trace_hat(&wtw, &q, lambda, m, n);
383
384    // 9. Fitted values: ŷ[i] = ȳ + Σ_j wc[i,j] · β[j]
385    let fitted_values: Vec<f64> = (0..n)
386        .map(|i| y_bar + (0..m).map(|j| wc[(i, j)] * beta[j]).sum::<f64>())
387        .collect();
388
389    Ok(PeerResult {
390        beta,
391        intercept: y_bar,
392        w_bar,
393        fitted_values,
394        effective_df,
395        lambda,
396        penalty_type: config.penalty.clone(),
397        gcv: gcv_score,
398        lambda_method,
399    })
400}
401
402/// Fit the longitudinal PEER scalar-on-function regression model with subject
403/// random effects.
404///
405/// Extends [`peer`] to grouped/repeated-measures data by reducing the
406/// functional predictor to FPC scores (via `fdata_to_pc`) and calling
407/// `famm::fit_scalar_mixed_model` for subject-level random intercepts.
408/// The PEER penalty (via `config`) regularises β(t) through the same λ
409/// dispatch as `peer()`; the mixed model then replaces the OLS second pass
410/// with a GLS+REML-EM pass over the FPC scores.
411///
412/// **ncomp cap:** `min(n − 1, m, 10)` — documented; prevents over-parameterisation
413/// at small n.
414///
415/// # Arguments
416///
417/// * `data`        — n×m functional predictor matrix.
418/// * `y`           — scalar response vector, length n.
419/// * `argvals`     — evaluation grid, length m.
420/// * `subject_map` — subject index per observation, length n. Non-contiguous
421///   IDs are re-indexed internally via `famm::build_subject_map`.
422/// * `config`      — penalty family and λ selection (same as `peer()`).
423///
424/// # Errors
425///
426/// Returns [`FdarError::InvalidDimension`] on dimension mismatches,
427/// [`FdarError::InvalidParameter`] when `n_subjects < 2` or argvals is
428/// non-monotone/non-finite, and [`FdarError::ComputationFailed`] when the
429/// mixed-model produces non-finite coefficients.
430///
431/// # Example
432///
433/// ```
434/// use fdars_core::matrix::FdMatrix;
435/// use fdars_core::peer::{lpeer, PeerConfig, PeerPenalty, LambdaChoice};
436///
437/// let (n, m) = (12_usize, 5_usize);
438/// let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
439/// let mut data = FdMatrix::zeros(n, m);
440/// let mut y = vec![0.0_f64; n];
441/// // 3 subjects × 4 observations each
442/// let subject_map: Vec<usize> = (0..n).map(|i| i / 4).collect();
443/// for i in 0..n {
444///     for j in 0..m {
445///         let xi = ((i * m + j) as f64 * 0.3).sin();
446///         data[(i, j)] = xi;
447///         y[i] += xi * 0.5;
448///     }
449/// }
450/// let mut config = PeerConfig::default();
451/// config.penalty = PeerPenalty::Ridge;
452/// config.lambda = LambdaChoice::Fixed(1e-2);
453/// let fit = lpeer(&data, &y, &argvals, &subject_map, &config).unwrap();
454/// assert_eq!(fit.beta.len(), m);
455/// assert!(fit.sigma2_subject >= 0.0);
456/// assert!(fit.sigma2_resid >= 0.0);
457/// let preds = fit.predict(&data, &argvals).unwrap();
458/// assert_eq!(preds.len(), n);
459/// ```
460pub fn lpeer(
461    data: &FdMatrix,
462    y: &[f64],
463    argvals: &[f64],
464    subject_map: &[usize],
465    config: &PeerConfig,
466) -> Result<LocalPeerResult, FdarError> {
467    let (n, m) = data.shape();
468
469    // --- Entry validation (mirrors peer()) ---
470    if n < 2 {
471        return Err(FdarError::InvalidDimension {
472            parameter: "data",
473            expected: "at least 2 observations".to_string(),
474            actual: format!("{n} rows"),
475        });
476    }
477    if m < 3 {
478        return Err(FdarError::InvalidDimension {
479            parameter: "data",
480            expected: "at least 3 evaluation points (m >= 3)".to_string(),
481            actual: format!("{m} columns"),
482        });
483    }
484    if argvals.len() != m {
485        return Err(FdarError::InvalidDimension {
486            parameter: "argvals",
487            expected: format!("{m}"),
488            actual: format!("{}", argvals.len()),
489        });
490    }
491    if y.len() != n {
492        return Err(FdarError::InvalidDimension {
493            parameter: "y",
494            expected: format!("{n}"),
495            actual: format!("{}", y.len()),
496        });
497    }
498    if subject_map.len() != n {
499        return Err(FdarError::InvalidDimension {
500            parameter: "subject_map",
501            expected: format!("{n}"),
502            actual: format!("{}", subject_map.len()),
503        });
504    }
505    if y.iter().any(|v| !v.is_finite()) {
506        return Err(FdarError::InvalidParameter {
507            parameter: "y",
508            message: "response contains non-finite values (NaN/Inf)".to_string(),
509        });
510    }
511    if argvals.iter().any(|v| !v.is_finite()) {
512        return Err(FdarError::InvalidParameter {
513            parameter: "argvals",
514            message: "argvals contains non-finite values (NaN/Inf)".to_string(),
515        });
516    }
517    if argvals.windows(2).any(|w| w[1] <= w[0]) {
518        return Err(FdarError::InvalidParameter {
519            parameter: "argvals",
520            message: "argvals must be strictly increasing".to_string(),
521        });
522    }
523
524    // Build dense 0-indexed subject map (handles non-contiguous IDs)
525    let (sm_dense, n_subjects) = crate::famm::build_subject_map(subject_map);
526    if n_subjects < 2 {
527        return Err(FdarError::InvalidParameter {
528            parameter: "subject_map",
529            message: "at least 2 distinct subjects required for lpeer".to_string(),
530        });
531    }
532
533    // 1. Integration weights + weighted design (identical to peer())
534    let w = simpsons_weights(argvals);
535    let mut wmat = FdMatrix::zeros(n, m);
536    for i in 0..n {
537        for j in 0..m {
538            wmat[(i, j)] = data[(i, j)] * w[j];
539        }
540    }
541
542    let y_bar: f64 = y.iter().sum::<f64>() / n as f64;
543    let yc: Vec<f64> = y.iter().map(|&yi| yi - y_bar).collect();
544
545    // w_bar computed identically to peer() so predict formula is identical
546    let w_bar: Vec<f64> = (0..m)
547        .map(|j| (0..n).map(|i| wmat[(i, j)]).sum::<f64>() / n as f64)
548        .collect();
549
550    let mut wc = FdMatrix::zeros(n, m);
551    for i in 0..n {
552        for j in 0..m {
553            wc[(i, j)] = wmat[(i, j)] - w_bar[j];
554        }
555    }
556
557    // 2. λ selection (identical dispatch to peer())
558    let q = build_q(m, &config.penalty)?;
559
560    let mut wtw = vec![0.0_f64; m * m];
561    for j in 0..m {
562        for k in j..m {
563            let s: f64 = (0..n).map(|i| wc[(i, j)] * wc[(i, k)]).sum();
564            wtw[j * m + k] = s;
565            wtw[k * m + j] = s;
566        }
567    }
568    let wty: Vec<f64> = (0..m)
569        .map(|j| (0..n).map(|i| wc[(i, j)] * yc[i]).sum())
570        .collect();
571
572    let (lambda, gcv_score, lambda_method) = match &config.lambda {
573        LambdaChoice::Fixed(lam) => (*lam, None, LambdaMethod::Fixed),
574        LambdaChoice::Gcv => {
575            let (lam, g) = select_lambda_gcv_peer(&wc, &yc, &wtw, &wty, &q, m, n);
576            (lam, Some(g), LambdaMethod::Gcv)
577        }
578        LambdaChoice::Reml => {
579            let lam = select_lambda_reml_peer(&wc, &yc, &q, m, n);
580            (lam, None, LambdaMethod::Reml)
581        }
582    };
583
584    // 3. FPC score reduction — cap at min(n-1, m, 10); raw scores, no h.sqrt() rescaling
585    let ncomp = (n - 1).min(m).min(10);
586    let fpca = crate::regression::fdata_to_pc(data, ncomp, argvals)?;
587    let scores = &fpca.scores; // n×ncomp FdMatrix (raw FPC scores)
588
589    // 4. Mixed-model fit over FPC scores (pass yc, not y — intercept = y_bar).
590    //    This estimates the subject-level random effect: σ²_subject (between-subject)
591    //    and σ²_resid, plus a baseline (unpenalized) fixed-effect γ.
592    let result =
593        crate::famm::fit_scalar_mixed_model(&yc, &sm_dense, n_subjects, Some(scores), ncomp);
594
595    // NaN guard on gamma
596    if result.gamma.iter().any(|v| !v.is_finite()) {
597        return Err(FdarError::ComputationFailed {
598            operation: "lpeer",
599            detail: "non-finite gamma from mixed model".into(),
600        });
601    }
602
603    // 5. Apply the PEER structured penalty λQ in the FPC-score space via a
604    //    penalized-GLS re-solve, reusing the mixed model's variance components.
605    //    The random-intercept marginal covariance is Σ = σ²_e·I + σ²_u·ZZ'; for a
606    //    subject g of size n_g, (Σ⁻¹v)_i = (v_i − s_g·σ²_u/(σ²_e+n_g·σ²_u))/σ²_e
607    //    with s_g the subject sum of v. In score space the β-penalty λ·β'Qβ becomes
608    //    λ·γ'(Φ'QΦ)γ, so γ solves (SᵀΣ⁻¹S + λ·Φ'QΦ)γ = SᵀΣ⁻¹y_c. λ (Fixed/GCV/REML)
609    //    now genuinely regularizes β(t); it is not a mere label.
610    let su = result.sigma2_u;
611    let se = result.sigma2_eps;
612    // Per-subject observation counts (for the random-intercept shrinkage).
613    let mut gcnt = vec![0usize; n_subjects];
614    for &g in &sm_dense {
615        gcnt[g] += 1;
616    }
617    // Σ⁻¹ applied to a length-n vector.
618    let sigma_inv = |v: &[f64]| -> Vec<f64> {
619        let mut gsum = vec![0.0_f64; n_subjects];
620        for i in 0..n {
621            gsum[sm_dense[i]] += v[i];
622        }
623        (0..n)
624            .map(|i| {
625                let g = sm_dense[i];
626                let shrink = su / (se + gcnt[g] as f64 * su);
627                (v[i] - shrink * gsum[g]) / se
628            })
629            .collect::<Vec<f64>>()
630    };
631    // Σ⁻¹ S  (n×ncomp)
632    let mut sinv_s = vec![0.0_f64; n * ncomp];
633    for k in 0..ncomp {
634        let col: Vec<f64> = (0..n).map(|i| scores[(i, k)]).collect();
635        let sc = sigma_inv(&col);
636        for i in 0..n {
637            sinv_s[i * ncomp + k] = sc[i];
638        }
639    }
640    // A = Sᵀ Σ⁻¹ S  (ncomp×ncomp) and b = Sᵀ Σ⁻¹ y_c  (ncomp)
641    let sinv_yc = sigma_inv(&yc);
642    let mut a_mat = vec![0.0_f64; ncomp * ncomp];
643    let mut b_vec = vec![0.0_f64; ncomp];
644    for k in 0..ncomp {
645        for l in 0..ncomp {
646            a_mat[k * ncomp + l] = (0..n).map(|i| scores[(i, k)] * sinv_s[i * ncomp + l]).sum();
647        }
648        b_vec[k] = (0..n).map(|i| scores[(i, k)] * sinv_yc[i]).sum();
649    }
650    // P = Φ'QΦ  (ncomp×ncomp): penalty λ·β'Qβ expressed on the FPC coefficients.
651    // QΦ  (m×ncomp)
652    let mut q_phi = vec![0.0_f64; m * ncomp];
653    for i in 0..m {
654        for l in 0..ncomp {
655            q_phi[i * ncomp + l] = (0..m)
656                .map(|jj| q[i * m + jj] * fpca.rotation[(jj, l)])
657                .sum();
658        }
659    }
660    // A_pen = A + λ·Φ'QΦ
661    for k in 0..ncomp {
662        for l in 0..ncomp {
663            let p_kl: f64 = (0..m)
664                .map(|i| fpca.rotation[(i, k)] * q_phi[i * ncomp + l])
665                .sum();
666            a_mat[k * ncomp + l] += lambda * p_kl;
667        }
668    }
669    // Penalized-GLS coefficients; fall back to the unpenalized mixed-model γ on failure.
670    let gamma = cholesky_solve(&a_mat, &b_vec, ncomp).unwrap_or_else(|_| result.gamma.clone());
671
672    // 6. Back-project γ → β(t): beta[j] = Σ_k gamma[k] * rotation[(j, k)]
673    let beta: Vec<f64> = (0..m)
674        .map(|j| {
675            (0..ncomp.min(gamma.len()))
676                .map(|k| gamma[k] * fpca.rotation[(j, k)])
677                .sum()
678        })
679        .collect();
680
681    if beta.iter().any(|v| !v.is_finite()) {
682        return Err(FdarError::ComputationFailed {
683            operation: "lpeer",
684            detail: "non-finite beta after back-projection".into(),
685        });
686    }
687
688    // 7. Fitted values (same formula as peer())
689    let base = y_bar - w_bar.iter().zip(&beta).map(|(wb, b)| wb * b).sum::<f64>();
690    let fitted_values: Vec<f64> = (0..n)
691        .map(|i| base + (0..m).map(|j| data[(i, j)] * w[j] * beta[j]).sum::<f64>())
692        .collect();
693
694    Ok(LocalPeerResult {
695        beta,
696        intercept: y_bar,
697        w_bar,
698        fitted_values,
699        sigma2_subject: result.sigma2_u,
700        sigma2_resid: result.sigma2_eps,
701        n_subjects,
702        lambda,
703        penalty_type: config.penalty.clone(),
704        gcv: gcv_score,
705        lambda_method,
706    })
707}
708
709impl LocalPeerResult {
710    /// Predict scalar responses for new functional observations (marginal prediction).
711    ///
712    /// Applies the fitted coefficient function β(t) to `new_data` via the same
713    /// formula as [`PeerResult::predict`]: new-subject random effect = 0 (marginal /
714    /// fixed-effect-only prediction).
715    ///
716    /// **Self-consistency:** re-passing the training data and argvals reproduces
717    /// the training [`fitted_values`](Self::fitted_values) to within 1e-9.
718    ///
719    /// # Errors
720    ///
721    /// Returns [`FdarError::InvalidDimension`] when `new_data.ncols()` or
722    /// `argvals.len()` does not equal the number of training grid points `m`.
723    pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
724        peer_predict_core(&self.beta, self.intercept, &self.w_bar, new_data, argvals)
725    }
726}
727
728// ---------------------------------------------------------------------------
729// Private helpers
730// ---------------------------------------------------------------------------
731
732/// Build the m×m penalty matrix Q (flat row-major) for the given family.
733fn build_q(m: usize, penalty: &PeerPenalty) -> Result<Vec<f64>, FdarError> {
734    match penalty {
735        PeerPenalty::Ridge => {
736            let mut q = vec![0.0_f64; m * m];
737            for i in 0..m {
738                q[i * m + i] = 1.0;
739            }
740            Ok(q)
741        }
742        PeerPenalty::Difference { order: 2 } => Ok(penalty_matrix(m)),
743        PeerPenalty::Difference { order } => Err(FdarError::InvalidParameter {
744            parameter: "penalty",
745            message: format!(
746                "Difference order {order} unsupported; only order 2 is available in this release"
747            ),
748        }),
749        PeerPenalty::Decree(q_raw, p_q) => {
750            if *p_q != m || q_raw.len() != m * m {
751                return Err(FdarError::InvalidDimension {
752                    parameter: "penalty",
753                    expected: format!("{m}x{m} ({} elems)", m * m),
754                    actual: format!("{p_q}x{p_q} ({} elems)", q_raw.len()),
755                });
756            }
757            Ok(q_raw.clone())
758        }
759    }
760}
761
762/// Compute effective df = tr(H) = tr(A^{-1} WtW) where A = WtW + λQ.
763///
764/// Uses the column-solve trick: for each column j of WtW, solve A z = WtW[:,j]
765/// and accumulate z[j].  Falls back to `m as f64` on Cholesky failure;
766/// clamps result to `n as f64`.
767fn compute_peer_trace_hat(wtw: &[f64], q: &[f64], lambda: f64, m: usize, n: usize) -> f64 {
768    let mut a = vec![0.0_f64; m * m];
769    for i in 0..m * m {
770        a[i] = wtw[i] + lambda * q[i];
771    }
772    let Ok(l) = cholesky_factor(&a, m) else {
773        return m as f64; // fallback
774    };
775    let mut trace = 0.0_f64;
776    for j in 0..m {
777        // Extract column j of WtW (stored row-major: WtW[row, col] = wtw[row*m + col])
778        let col: Vec<f64> = (0..m).map(|i| wtw[i * m + j]).collect();
779        let z = cholesky_forward_back(&l, &col, m);
780        trace += z[j];
781    }
782    trace.min(n as f64)
783}
784
785/// Build a 40-point log-spaced grid on [1e-6, 1e4].
786///
787/// Grid point i is `10^(-6 + 10·i/39)` for i in 0..40.  The grid is purely
788/// a function of constants — no RNG — so GCV selection is fully deterministic.
789fn gcv_lambda_grid() -> Vec<f64> {
790    (0..40)
791        .map(|i| 10.0_f64.powf(-6.0 + 10.0 * i as f64 / 39.0))
792        .collect()
793}
794
795/// Select λ by minimising the GCV score over the fixed 40-point grid.
796///
797/// GCV score: `GCV(λ) = n·RSS(λ) / (n − tr(H(λ)))²`.
798///
799/// Tie-break: when two grid points share the minimum GCV (within floating-point
800/// equality), the smaller grid index (smaller λ) is retained.
801///
802/// Guard: if `n − tr(H) ≤ 0` for a candidate (degenerate, over-smoothed), that
803/// grid point is skipped.  If no grid point yields a finite score, the smallest
804/// grid λ is returned with score `f64::INFINITY`.
805///
806/// Returns `(best_lambda, gcv_at_best)`.
807fn select_lambda_gcv_peer(
808    wc: &FdMatrix,
809    yc: &[f64],
810    wtw: &[f64],
811    wty: &[f64],
812    q: &[f64],
813    m: usize,
814    n: usize,
815) -> (f64, f64) {
816    let grid = gcv_lambda_grid();
817    let mut best_lam = grid[0];
818    let mut best_gcv = f64::INFINITY;
819
820    for &lam in &grid {
821        // Build A = WtW + lam*Q
822        let mut a = vec![0.0_f64; m * m];
823        for i in 0..m * m {
824            a[i] = wtw[i] + lam * q[i];
825        }
826        // Solve for beta
827        let Ok(beta) = cholesky_solve(&a, wty, m) else {
828            continue;
829        };
830        // RSS = ||y_c - W_c beta||^2
831        let rss: f64 = (0..n)
832            .map(|i| {
833                let yhat: f64 = (0..m).map(|j| wc[(i, j)] * beta[j]).sum();
834                (yc[i] - yhat).powi(2)
835            })
836            .sum();
837        // tr(H) reuses compute_peer_trace_hat
838        let trh = compute_peer_trace_hat(wtw, q, lam, m, n);
839        // Guard: skip if denominator <= 0
840        let denom = n as f64 - trh;
841        if denom <= 0.0 {
842            continue;
843        }
844        let gcv = n as f64 * rss / (denom * denom);
845        // Strict < keeps first (smaller-λ) grid index on ties
846        if gcv < best_gcv {
847            best_gcv = gcv;
848            best_lam = lam;
849        }
850    }
851    (best_lam, best_gcv)
852}
853
854/// Select λ via a self-contained REML EM using the eigendecomposition of Q.
855///
856/// The PEER-as-mixed-model equivalence partitions the m-dimensional coefficient
857/// space into:
858/// - Null space of Q (s dimensions) → fixed (unpenalised) effect α.
859/// - Range space of Q (r dimensions) → random effect b ∼ N(0, σ²_u I_r).
860///
861/// An EM loop estimates σ²_u and σ²_e; the returned λ = σ²_e / σ²_u.
862///
863/// Edge cases:
864/// - Ridge (Q = I_m, s = 0): GLS α-update is skipped; r_alpha = y_c.
865/// - Zero-Q Decree (r = 0): returns the documented fallback λ = 1e-4.
866/// - σ²_u → 0: clamped to 1e-12 (keeps λ finite).
867///
868/// Deterministic: fixed initialisation + 100-iteration cap, no RNG.
869fn select_lambda_reml_peer(wc: &FdMatrix, yc: &[f64], q: &[f64], m: usize, n: usize) -> f64 {
870    // --- STEP 1: eigendecompose Q (ascending eigenvalue order, null space first) ---
871    let q_mat = DMatrix::from_row_slice(m, m, q);
872    let eigen = q_mat.symmetric_eigen();
873
874    let mut idx_sorted: Vec<usize> = (0..m).collect();
875    idx_sorted.sort_by(|&a, &b| {
876        eigen.eigenvalues[a]
877            .partial_cmp(&eigen.eigenvalues[b])
878            .unwrap_or(std::cmp::Ordering::Equal)
879    });
880
881    let max_ev = idx_sorted
882        .iter()
883        .map(|&i| eigen.eigenvalues[i].abs())
884        .fold(0.0_f64, f64::max);
885    let tol = 1e-8 * max_ev.max(1.0);
886
887    let null_idx: Vec<usize> = idx_sorted
888        .iter()
889        .copied()
890        .filter(|&i| eigen.eigenvalues[i].abs() < tol)
891        .collect();
892    let range_idx: Vec<usize> = idx_sorted
893        .iter()
894        .copied()
895        .filter(|&i| eigen.eigenvalues[i].abs() >= tol)
896        .collect();
897    let s = null_idx.len();
898    let r = range_idx.len();
899
900    // --- STEP 2: edge — zero-Q Decree (range space empty) ---
901    if r == 0 {
902        return 1e-4; // documented fallback: no random effect to estimate
903    }
904
905    // --- STEP 3: project designs into eigenbasis ---
906    // z_null[i*s + col]  = Σ_j wc[(i,j)] * V_null[(j, col)]
907    // z_range[i*r + col] = Σ_j wc[(i,j)] * V_range[(j, col)]
908    let mut z_null = vec![0.0_f64; n * s];
909    let mut z_range = vec![0.0_f64; n * r];
910    for i in 0..n {
911        for (col, &ev_idx) in null_idx.iter().enumerate() {
912            let mut val = 0.0;
913            for j in 0..m {
914                val += wc[(i, j)] * eigen.eigenvectors[(j, ev_idx)];
915            }
916            z_null[i * s + col] = val;
917        }
918        for (col, &ev_idx) in range_idx.iter().enumerate() {
919            let mut val = 0.0;
920            for j in 0..m {
921                val += wc[(i, j)] * eigen.eigenvectors[(j, ev_idx)];
922            }
923            z_range[i * r + col] = val;
924        }
925    }
926
927    // Pre-compute ZtZ_range (r×r, row-major, symmetric) — built once, reused each iter
928    let mut ztz_range = vec![0.0_f64; r * r];
929    for a in 0..r {
930        for b in a..r {
931            let s_val: f64 = (0..n)
932                .map(|i| z_range[i * r + a] * z_range[i * r + b])
933                .sum();
934            ztz_range[a * r + b] = s_val;
935            ztz_range[b * r + a] = s_val;
936        }
937    }
938
939    // --- STEP 4: deterministic init (no RNG) ---
940    let y_mean = yc.iter().sum::<f64>() / n as f64;
941    let y_var = yc.iter().map(|&v| (v - y_mean).powi(2)).sum::<f64>() / (n - 1).max(1) as f64;
942    let mut sigma2_e = y_var.max(1e-12);
943    let mut sigma2_u = (sigma2_e * 0.1).max(1e-12);
944
945    // OLS init of alpha (if s > 0): solve (Z_null' Z_null + 1e-10 I_s) alpha = Z_null' yc
946    let mut alpha = vec![0.0_f64; s];
947    if s > 0 {
948        // ZtZ_null (s×s) + ridge
949        let mut ztz_null = vec![0.0_f64; s * s];
950        for a in 0..s {
951            for b in a..s {
952                let sv: f64 = (0..n).map(|i| z_null[i * s + a] * z_null[i * s + b]).sum();
953                ztz_null[a * s + b] = sv;
954                ztz_null[b * s + a] = sv;
955            }
956        }
957        for diag in 0..s {
958            ztz_null[diag * s + diag] += 1e-10;
959        }
960        // Z_null' yc
961        let zty_null: Vec<f64> = (0..s)
962            .map(|col| (0..n).map(|i| z_null[i * s + col] * yc[i]).sum())
963            .collect();
964        if let Ok(a_init) = cholesky_solve(&ztz_null, &zty_null, s) {
965            alpha = a_init;
966        }
967    }
968
969    // --- STEP 5: EM loop, fixed cap 100 iterations ---
970    for _iter in 0..100 {
971        let su_old = sigma2_u;
972        let se_old = sigma2_e;
973
974        // r_alpha = yc - Z_null * alpha  (residual after fixed effects)
975        let r_alpha: Vec<f64> = if s > 0 {
976            (0..n)
977                .map(|i| {
978                    let za: f64 = (0..s).map(|col| z_null[i * s + col] * alpha[col]).sum();
979                    yc[i] - za
980                })
981                .collect()
982        } else {
983            yc.to_vec()
984        };
985
986        // E-step: M = ZtZ_range/sigma2_e + I_r/sigma2_u  (r×r)
987        let mut big_m = vec![0.0_f64; r * r];
988        for idx in 0..r * r {
989            big_m[idx] = ztz_range[idx] / sigma2_e;
990        }
991        for diag in 0..r {
992            big_m[diag * r + diag] += 1.0 / sigma2_u;
993        }
994
995        // Cholesky of the E-step matrix M. On failure the EM cannot advance, so
996        // restore the last stable variance components and stop — the function then
997        // returns λ = σ²_e/σ²_u from those last-good values (defined graceful
998        // fallback, never a panic or NaN; WR-03).
999        let l_m = match cholesky_factor(&big_m, r) {
1000            Ok(l) => l,
1001            Err(_) => {
1002                sigma2_u = su_old;
1003                sigma2_e = se_old;
1004                break;
1005            }
1006        };
1007
1008        // Sigma_b = M^{-1}: solve column by column (r solves)
1009        let mut sigma_b = vec![0.0_f64; r * r];
1010        let mut trace_sigma_b = 0.0_f64;
1011        for col in 0..r {
1012            let mut e_col = vec![0.0_f64; r];
1013            e_col[col] = 1.0;
1014            let sol = cholesky_forward_back(&l_m, &e_col, r);
1015            for row in 0..r {
1016                sigma_b[row * r + col] = sol[row];
1017            }
1018            trace_sigma_b += sol[col];
1019        }
1020
1021        // Z_range' r_alpha  (length r)
1022        let ztr: Vec<f64> = (0..r)
1023            .map(|col| (0..n).map(|i| z_range[i * r + col] * r_alpha[i]).sum())
1024            .collect();
1025
1026        // b_hat = Sigma_b * ztr / sigma2_e  (length r)
1027        let b_hat: Vec<f64> = (0..r)
1028            .map(|row| {
1029                (0..r)
1030                    .map(|col| sigma_b[row * r + col] * ztr[col])
1031                    .sum::<f64>()
1032                    / sigma2_e
1033            })
1034            .collect();
1035
1036        // M-step: sigma2_u_new = (b_hat'b_hat + tr(Sigma_b)) / r
1037        let b_sq: f64 = b_hat.iter().map(|&v| v * v).sum();
1038        let sigma2_u_new = (b_sq + trace_sigma_b) / r as f64;
1039
1040        // resid = r_alpha - Z_range * b_hat  (length n)
1041        let resid: Vec<f64> = (0..n)
1042            .map(|i| {
1043                let zb: f64 = (0..r).map(|col| z_range[i * r + col] * b_hat[col]).sum();
1044                r_alpha[i] - zb
1045            })
1046            .collect();
1047
1048        // tr(Z_range Sigma_b Z_range') = tr(Sigma_b ZtZ_range) = Σ_{a,j} sigma_b[a*r+j]*ztz_range[j*r+a]
1049        let tr_zsz: f64 = (0..r)
1050            .map(|a| {
1051                (0..r)
1052                    .map(|j| sigma_b[a * r + j] * ztz_range[j * r + a])
1053                    .sum::<f64>()
1054            })
1055            .sum();
1056
1057        let resid_sq: f64 = resid.iter().map(|&v| v * v).sum();
1058        let sigma2_e_new = (resid_sq + tr_zsz) / n as f64;
1059
1060        // Clamp both variance components to a positive floor BEFORE they are used
1061        // as divisors in the GLS block below — an unclamped σ²_e ≈ 0 would divide
1062        // to Inf/NaN in the Woodbury solve (WR-02).
1063        let sigma2_u_c = sigma2_u_new.max(1e-12);
1064        let sigma2_e_c = sigma2_e_new.max(1e-12);
1065
1066        // GLS null-space update (only when s > 0) using Woodbury identity
1067        // Sigma^{-1} = (1/sigma2_e)(I_n - Z_range * K^{-1} * Z_range')
1068        // where K = sigma2_e/sigma2_u * I_r + ZtZ_range  (r×r)
1069        if s > 0 {
1070            // Build K = ZtZ_range + (sigma2_e/sigma2_u)*I_r
1071            let ratio = sigma2_e_c / sigma2_u_c;
1072            let mut k_mat = ztz_range.clone();
1073            for diag in 0..r {
1074                k_mat[diag * r + diag] += ratio;
1075            }
1076            // Cholesky of K for the Woodbury inverse
1077            if let Ok(l_k) = cholesky_factor(&k_mat, r) {
1078                // Compute Sigma^{-1} Z_null and Z_null' Sigma^{-1} Z_null for the s×s GLS system
1079                // Sigma^{-1} z_null_col = (1/sigma2_e)(z_null_col - Z_range * K^{-1} Z_range' z_null_col)
1080                let mut gls_lhs = vec![0.0_f64; s * s]; // Z_null' Sigma^{-1} Z_null  (s×s)
1081                let mut gls_rhs = vec![0.0_f64; s]; // Z_null' Sigma^{-1} y_c  (length s)
1082
1083                // Precompute Z_range' y_c (length r)
1084                let zr_yc: Vec<f64> = (0..r)
1085                    .map(|col| (0..n).map(|i| z_range[i * r + col] * yc[i]).sum())
1086                    .collect();
1087                // K^{-1} Z_range' y_c  (length r)
1088                let kinv_zr_yc = cholesky_forward_back(&l_k, &zr_yc, r);
1089                // Sigma^{-1} y_c  (length n): (1/sigma2_e)(y_c - Z_range * K^{-1} Z_range' y_c)
1090                let sinv_yc: Vec<f64> = (0..n)
1091                    .map(|i| {
1092                        let zk: f64 = (0..r)
1093                            .map(|col| z_range[i * r + col] * kinv_zr_yc[col])
1094                            .sum();
1095                        (yc[i] - zk) / sigma2_e_c
1096                    })
1097                    .collect();
1098
1099                for col_null in 0..s {
1100                    // Z_range' z_null_col  (length r)
1101                    let zr_zn: Vec<f64> = (0..r)
1102                        .map(|col| {
1103                            (0..n)
1104                                .map(|i| z_range[i * r + col] * z_null[i * s + col_null])
1105                                .sum()
1106                        })
1107                        .collect();
1108                    // K^{-1} Z_range' z_null_col
1109                    let kinv_zr_zn = cholesky_forward_back(&l_k, &zr_zn, r);
1110                    // Sigma^{-1} z_null_col  (length n)
1111                    let sinv_zn: Vec<f64> = (0..n)
1112                        .map(|i| {
1113                            let zk: f64 = (0..r)
1114                                .map(|col| z_range[i * r + col] * kinv_zr_zn[col])
1115                                .sum();
1116                            (z_null[i * s + col_null] - zk) / sigma2_e_c
1117                        })
1118                        .collect();
1119
1120                    // Z_null' Sigma^{-1} z_null_col → column col_null of gls_lhs
1121                    for row_null in 0..s {
1122                        let v: f64 = (0..n).map(|i| z_null[i * s + row_null] * sinv_zn[i]).sum();
1123                        gls_lhs[row_null * s + col_null] = v;
1124                    }
1125                    // Z_null' Sigma^{-1} y_c: entry col_null of gls_rhs
1126                    let rhs_val: f64 = (0..n).map(|i| z_null[i * s + col_null] * sinv_yc[i]).sum();
1127                    gls_rhs[col_null] = rhs_val;
1128                }
1129
1130                // Add ridge to GLS LHS for numerical stability
1131                for diag in 0..s {
1132                    gls_lhs[diag * s + diag] += 1e-10;
1133                }
1134
1135                if let Ok(alpha_new) = cholesky_solve(&gls_lhs, &gls_rhs, s) {
1136                    alpha = alpha_new;
1137                }
1138            }
1139        }
1140
1141        // Commit the clamped variance components for this iteration.
1142        sigma2_u = sigma2_u_c;
1143        sigma2_e = sigma2_e_c;
1144
1145        // Measure convergence on the PRE-clamp M-step updates so a component
1146        // resting on the 1e-12 floor two iterations running is not mistaken for
1147        // genuine convergence (WR-01) — that would return λ = σ²_e/floor
1148        // (extreme over-smoothing) instead of the REML optimum.
1149        let delta = (sigma2_u_new - su_old).abs() + (sigma2_e_new - se_old).abs();
1150        if delta < 1e-8 * (su_old + se_old) {
1151            break;
1152        }
1153    }
1154
1155    // --- STEP 6: return λ = σ²_e / σ²_u ---
1156    (sigma2_e / sigma2_u).max(1e-15)
1157}
1158
1159// ---------------------------------------------------------------------------
1160// Shared prediction helper
1161// ---------------------------------------------------------------------------
1162
1163/// Shared prediction core for both `PeerResult` and `LocalPeerResult`.
1164///
1165/// Computes out-of-sample predictions via
1166/// `ŷ*[i] = (intercept − w_bar·β) + Σ_j x*[i,j] · w[j] · β[j]`
1167/// where `w = simpsons_weights(argvals)`.
1168///
1169/// Self-consistency: re-passing the training `data` and `argvals` reproduces
1170/// the training `fitted_values` to within floating-point rounding (≤ 1e-9).
1171fn peer_predict_core(
1172    beta: &[f64],
1173    intercept: f64,
1174    w_bar: &[f64],
1175    new_data: &FdMatrix,
1176    argvals: &[f64],
1177) -> Result<Vec<f64>, FdarError> {
1178    let (n_new, m_new) = new_data.shape();
1179    let m = beta.len();
1180    if m_new != m {
1181        return Err(FdarError::InvalidDimension {
1182            parameter: "new_data",
1183            expected: format!("{m} columns (training grid length)"),
1184            actual: format!("{m_new}"),
1185        });
1186    }
1187    if argvals.len() != m {
1188        return Err(FdarError::InvalidDimension {
1189            parameter: "argvals",
1190            expected: format!("{m}"),
1191            actual: format!("{}", argvals.len()),
1192        });
1193    }
1194    // Simpson's weights assume a strictly increasing grid; a non-monotone grid
1195    // yields negative weights that would silently corrupt the prediction.
1196    if argvals.windows(2).any(|w| w[1] <= w[0]) {
1197        return Err(FdarError::InvalidParameter {
1198            parameter: "argvals",
1199            message: "argvals must be strictly increasing".to_string(),
1200        });
1201    }
1202    // w_bar always has length m for a well-formed fit result; assert in debug to
1203    // catch a corrupted result before it silently truncates the base offset.
1204    debug_assert_eq!(w_bar.len(), m, "w_bar length must equal beta length");
1205    let w = simpsons_weights(argvals);
1206    let base: f64 = intercept - w_bar.iter().zip(beta).map(|(wb, b)| wb * b).sum::<f64>();
1207    let preds: Vec<f64> = (0..n_new)
1208        .map(|i| {
1209            base + (0..m)
1210                .map(|j| new_data[(i, j)] * w[j] * beta[j])
1211                .sum::<f64>()
1212        })
1213        .collect();
1214    Ok(preds)
1215}
1216
1217impl PeerResult {
1218    /// Predict scalar responses for new functional observations.
1219    ///
1220    /// Applies the fitted PEER coefficient function β(t) to `new_data` via
1221    /// `ŷ*[i] = (intercept − w_bar·β) + Σ_j x*[i,j] · w[j] · β[j]`
1222    /// where `w = simpsons_weights(argvals)`.
1223    ///
1224    /// **Self-consistency:** re-passing the training data and training `argvals`
1225    /// reproduces the training [`fitted_values`](Self::fitted_values) to within
1226    /// floating-point rounding (≤ 1e-9 in absolute error per observation).
1227    ///
1228    /// # Errors
1229    ///
1230    /// Returns [`FdarError::InvalidDimension`] when `new_data.ncols()` or
1231    /// `argvals.len()` does not equal the number of training grid points `m`.
1232    pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
1233        peer_predict_core(&self.beta, self.intercept, &self.w_bar, new_data, argvals)
1234    }
1235}
1236
1237// ---------------------------------------------------------------------------
1238// Tests
1239// ---------------------------------------------------------------------------
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244    use crate::test_helpers::uniform_grid;
1245
1246    /// Stateless deterministic pseudo-random unit in [-1, 1] (splitmix64 finalizer).
1247    /// Used to build spanning design curves without an RNG dependency.
1248    fn hash_unit(k: u64) -> f64 {
1249        let mut z = k
1250            .wrapping_add(0x9E37_79B9_7F4A_7C15)
1251            .wrapping_mul(0xBF58_476D_1CE4_E5B9);
1252        z = (z ^ (z >> 30)).wrapping_mul(0x94D0_49BB_1331_11EB);
1253        z ^= z >> 31;
1254        ((z >> 11) as f64 / (1_u64 << 53) as f64) * 2.0 - 1.0
1255    }
1256
1257    /// Build the shared synthetic fixture: n=200 curves, m=40 grid, true β(t)=sin(π·t).
1258    /// Generates y_i = Σ_j X_i(t_j)·β(t_j)·w_j + small_noise.
1259    ///
1260    /// The design curves are deterministic pseudo-random (spanning the full m-dim
1261    /// grid space, so W has full column rank and β(t) is identifiable) — a smooth
1262    /// low-rank family (e.g. phase-shifted single-frequency sinusoids) would live
1263    /// in a 2-D subspace and leave β(t) unrecoverable regardless of the estimator.
1264    /// n ≫ m keeps W'W well-conditioned and makes the fixed λ negligible relative to
1265    /// the signal, so the penalty bias stays well under the recovery tolerance.
1266    fn make_fixture() -> (FdMatrix, Vec<f64>, Vec<f64>, Vec<f64>) {
1267        let (n, m) = (200_usize, 40_usize);
1268        let t = uniform_grid(m);
1269        let true_beta: Vec<f64> = t
1270            .iter()
1271            .map(|&ti| (std::f64::consts::PI * ti).sin())
1272            .collect();
1273        let w = simpsons_weights(&t);
1274
1275        let mut data = FdMatrix::zeros(n, m);
1276        let mut y = vec![0.0_f64; n];
1277        for i in 0..n {
1278            for j in 0..m {
1279                let xi = hash_unit((i * m + j) as u64);
1280                data[(i, j)] = xi;
1281                y[i] += xi * true_beta[j] * w[j];
1282            }
1283            // Small deterministic noise
1284            y[i] += 0.005 * hash_unit(1_000_000 + i as u64);
1285        }
1286        (data, y, t, true_beta)
1287    }
1288
1289    // -------------------------------------------------------------------------
1290    // Phase 66 migrated tests: Difference{2} tracer
1291    // -------------------------------------------------------------------------
1292
1293    #[test]
1294    fn test_peer_difference_beta_recovery() {
1295        let (data, y, t, true_beta) = make_fixture();
1296        let config = PeerConfig {
1297            penalty: PeerPenalty::Difference { order: 2 },
1298            lambda: LambdaChoice::Fixed(1e-4),
1299        };
1300        let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1301
1302        let max_err = result
1303            .beta
1304            .iter()
1305            .zip(true_beta.iter())
1306            .map(|(a, b)| (a - b).abs())
1307            .fold(0.0_f64, f64::max);
1308        assert!(
1309            max_err < 0.1,
1310            "Difference beta recovery error: {max_err} >= 0.1"
1311        );
1312        assert!(
1313            result.fitted_values.iter().all(|v| v.is_finite()),
1314            "fitted_values contain non-finite values"
1315        );
1316    }
1317
1318    #[test]
1319    fn test_peer_result_shape() {
1320        let (data, y, t, _) = make_fixture();
1321        let m = t.len();
1322        let n = y.len();
1323        let config = PeerConfig {
1324            penalty: PeerPenalty::Difference { order: 2 },
1325            lambda: LambdaChoice::Fixed(1e-4),
1326        };
1327        let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1328
1329        assert_eq!(result.beta.len(), m, "beta length should be m={m}");
1330        assert_eq!(
1331            result.fitted_values.len(),
1332            n,
1333            "fitted_values length should be n={n}"
1334        );
1335        assert!(
1336            result.effective_df.is_finite() && result.effective_df > 0.0,
1337            "effective_df={} must be finite and positive",
1338            result.effective_df
1339        );
1340        assert!(
1341            (result.lambda - 1e-4).abs() < 1e-15,
1342            "lambda should be 1e-4, got {}",
1343            result.lambda
1344        );
1345        assert_eq!(
1346            result.penalty_type,
1347            PeerPenalty::Difference { order: 2 },
1348            "penalty_type should be Difference{{order:2}}"
1349        );
1350
1351        let y_bar = y.iter().sum::<f64>() / n as f64;
1352        assert!(
1353            (result.intercept - y_bar).abs() < 1e-9,
1354            "intercept={} should equal mean(y)={y_bar} within 1e-9",
1355            result.intercept
1356        );
1357    }
1358
1359    // -------------------------------------------------------------------------
1360    // Phase 66 migrated tests: all three penalty families
1361    // -------------------------------------------------------------------------
1362
1363    #[test]
1364    fn test_peer_ridge_fits() {
1365        let (data, y, t, _) = make_fixture();
1366        let m = t.len();
1367        let config = PeerConfig {
1368            penalty: PeerPenalty::Ridge,
1369            lambda: LambdaChoice::Fixed(1e-4),
1370        };
1371        let result = peer(&data, &y, &t, &config).expect("peer() with Ridge should succeed");
1372        assert_eq!(result.beta.len(), m);
1373        assert!(
1374            result.beta.iter().all(|v| v.is_finite()),
1375            "Ridge beta contains non-finite values"
1376        );
1377        assert!(
1378            result.fitted_values.iter().all(|v| v.is_finite()),
1379            "Ridge fitted_values contain non-finite values"
1380        );
1381    }
1382
1383    #[test]
1384    fn test_peer_decree_fits() {
1385        let (data, y, t, _) = make_fixture();
1386        let m = t.len();
1387        // Use the same penalty_matrix as a caller-supplied Decree matrix
1388        let q_flat = penalty_matrix(m);
1389        let config = PeerConfig {
1390            penalty: PeerPenalty::Decree(q_flat, m),
1391            lambda: LambdaChoice::Fixed(1e-4),
1392        };
1393        let result = peer(&data, &y, &t, &config).expect("peer() with Decree should succeed");
1394        assert_eq!(result.beta.len(), m);
1395        assert!(
1396            result.beta.iter().all(|v| v.is_finite()),
1397            "Decree beta contains non-finite values"
1398        );
1399        assert!(
1400            result.fitted_values.iter().all(|v| v.is_finite()),
1401            "Decree fitted_values contain non-finite values"
1402        );
1403    }
1404
1405    #[test]
1406    fn test_peer_difference_order_rejected() {
1407        let (data, y, t, _) = make_fixture();
1408        let config = PeerConfig {
1409            penalty: PeerPenalty::Difference { order: 3 },
1410            lambda: LambdaChoice::Fixed(1e-4),
1411        };
1412        let err = peer(&data, &y, &t, &config).expect_err("order 3 should be rejected");
1413        assert!(
1414            matches!(err, FdarError::InvalidParameter { .. }),
1415            "expected InvalidParameter, got {err:?}"
1416        );
1417    }
1418
1419    // -------------------------------------------------------------------------
1420    // Phase 66 migrated: Decree partition-structured Q yields β(t) distinct from Difference{2}
1421    // -------------------------------------------------------------------------
1422
1423    #[test]
1424    fn test_peer_decree_distinct_from_roughness() {
1425        let (data, y, t, _) = make_fixture();
1426        let m = t.len();
1427        // Use a moderate lambda so the penalty visibly shapes β(t)
1428        let lambda = LambdaChoice::Fixed(1.0);
1429
1430        // Fit (a) with plain Difference{2}
1431        let config_diff = PeerConfig {
1432            penalty: PeerPenalty::Difference { order: 2 },
1433            lambda: lambda.clone(),
1434        };
1435        let res_diff = peer(&data, &y, &t, &config_diff).expect("Difference{2} fit should succeed");
1436
1437        // Build a partition-aware Q: a second-difference operator whose stencil
1438        // is NOT applied across the mid-grid boundary b = m/2.
1439        // This leaves the two halves independently penalized.
1440        let b = m / 2;
1441        let mut q_partition = vec![0.0_f64; m * m];
1442        for i in 0..m.saturating_sub(2) {
1443            // Skip stencil rows that straddle the boundary
1444            if i + 1 == b || i + 2 == b || i == b {
1445                continue;
1446            }
1447            let coeffs = [(i, 1.0_f64), (i + 1, -2.0), (i + 2, 1.0)];
1448            for &(r, cr) in &coeffs {
1449                for &(c, cc) in &coeffs {
1450                    q_partition[r * m + c] += cr * cc;
1451                }
1452            }
1453        }
1454        // Add a small ridge to each diagonal to ensure PSD for Cholesky stability
1455        for i in 0..m {
1456            q_partition[i * m + i] += 1e-6;
1457        }
1458
1459        let config_dec = PeerConfig {
1460            penalty: PeerPenalty::Decree(q_partition, m),
1461            lambda,
1462        };
1463        let res_dec =
1464            peer(&data, &y, &t, &config_dec).expect("Decree partition fit should succeed");
1465
1466        // Both fits must be all-finite
1467        assert!(
1468            res_diff.beta.iter().all(|v| v.is_finite()),
1469            "Difference beta contains non-finite"
1470        );
1471        assert!(
1472            res_dec.beta.iter().all(|v| v.is_finite()),
1473            "Decree beta contains non-finite"
1474        );
1475
1476        // The two β(t) must differ meaningfully (> 1e-3 max pointwise)
1477        let max_diff = res_diff
1478            .beta
1479            .iter()
1480            .zip(res_dec.beta.iter())
1481            .map(|(a, b)| (a - b).abs())
1482            .fold(0.0_f64, f64::max);
1483        assert!(
1484            max_diff > 1e-3,
1485            "Decree partition Q should yield a β(t) distinct from Difference{{2}} \
1486             by > 1e-3; got max_diff={max_diff}"
1487        );
1488    }
1489
1490    // -------------------------------------------------------------------------
1491    // Phase 66 migrated: Error/NaN surface — wrong-dim Q, dim mismatch, no NaN
1492    // -------------------------------------------------------------------------
1493
1494    #[test]
1495    fn test_peer_decree_wrong_dim() {
1496        let (data, y, t, _) = make_fixture();
1497        let m = t.len();
1498        // Q sized (m-1)×(m-1) ≠ m×m
1499        let config = PeerConfig {
1500            penalty: PeerPenalty::Decree(vec![0.0; (m - 1) * (m - 1)], m - 1),
1501            lambda: LambdaChoice::Fixed(1.0),
1502        };
1503        let err = peer(&data, &y, &t, &config).expect_err("wrong-dim Decree should return Err");
1504        assert!(
1505            matches!(err, FdarError::InvalidDimension { .. }),
1506            "expected InvalidDimension, got {err:?}"
1507        );
1508    }
1509
1510    #[test]
1511    fn test_peer_argvals_mismatch() {
1512        let (data, y, t, _) = make_fixture();
1513        let m = t.len();
1514        // argvals has length m+1 instead of m
1515        let argvals_bad: Vec<f64> = (0..=m).map(|i| i as f64 / m as f64).collect();
1516        let config = PeerConfig::default();
1517        let err = peer(&data, &y, &argvals_bad, &config)
1518            .expect_err("mismatched argvals should return Err");
1519        assert!(
1520            matches!(err, FdarError::InvalidDimension { .. }),
1521            "expected InvalidDimension, got {err:?}"
1522        );
1523    }
1524
1525    #[test]
1526    fn test_peer_no_nan_all_families() {
1527        let (data, y, t, _) = make_fixture();
1528        let m = t.len();
1529        let lambda = LambdaChoice::Fixed(1.0);
1530
1531        // Ridge
1532        let config_ridge = PeerConfig {
1533            penalty: PeerPenalty::Ridge,
1534            lambda: lambda.clone(),
1535        };
1536        let res_ridge = peer(&data, &y, &t, &config_ridge).expect("Ridge should succeed");
1537        assert!(
1538            res_ridge.beta.iter().all(|v| v.is_finite()),
1539            "Ridge beta contains NaN/Inf"
1540        );
1541        assert!(
1542            res_ridge.fitted_values.iter().all(|v| v.is_finite()),
1543            "Ridge fitted_values contains NaN/Inf"
1544        );
1545        assert!(
1546            res_ridge.effective_df.is_finite(),
1547            "Ridge effective_df is not finite"
1548        );
1549
1550        // Difference{2}
1551        let config_diff = PeerConfig {
1552            penalty: PeerPenalty::Difference { order: 2 },
1553            lambda: lambda.clone(),
1554        };
1555        let res_diff = peer(&data, &y, &t, &config_diff).expect("Difference{2} should succeed");
1556        assert!(
1557            res_diff.beta.iter().all(|v| v.is_finite()),
1558            "Difference beta contains NaN/Inf"
1559        );
1560        assert!(
1561            res_diff.fitted_values.iter().all(|v| v.is_finite()),
1562            "Difference fitted_values contains NaN/Inf"
1563        );
1564        assert!(
1565            res_diff.effective_df.is_finite(),
1566            "Difference effective_df is not finite"
1567        );
1568
1569        // Decree using penalty_matrix(m) as a valid caller-supplied Q
1570        let q_flat = penalty_matrix(m);
1571        let config_dec = PeerConfig {
1572            penalty: PeerPenalty::Decree(q_flat, m),
1573            lambda,
1574        };
1575        let res_dec = peer(&data, &y, &t, &config_dec).expect("Decree should succeed");
1576        assert!(
1577            res_dec.beta.iter().all(|v| v.is_finite()),
1578            "Decree beta contains NaN/Inf"
1579        );
1580        assert!(
1581            res_dec.fitted_values.iter().all(|v| v.is_finite()),
1582            "Decree fitted_values contains NaN/Inf"
1583        );
1584        assert!(
1585            res_dec.effective_df.is_finite(),
1586            "Decree effective_df is not finite"
1587        );
1588    }
1589
1590    // -------------------------------------------------------------------------
1591    // Phase 66 migrated: Prediction-contract + hardened-validation tests
1592    // -------------------------------------------------------------------------
1593
1594    #[test]
1595    fn test_peer_stores_w_bar_for_prediction() {
1596        // The stored w_bar must let an out-of-sample predictor reproduce the
1597        // training fitted values exactly via ŷ = (ȳ − w_bar·β) + Σ_j x[j]·w[j]·β[j].
1598        let (data, y, t, _) = make_fixture();
1599        let (n, m) = data.shape();
1600        let w = simpsons_weights(&t);
1601        let config = PeerConfig {
1602            penalty: PeerPenalty::Difference { order: 2 },
1603            lambda: LambdaChoice::Fixed(1e-4),
1604        };
1605        let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1606
1607        assert_eq!(result.w_bar.len(), m, "w_bar length must equal m");
1608
1609        let base = result.intercept
1610            - result
1611                .w_bar
1612                .iter()
1613                .zip(&result.beta)
1614                .map(|(wb, b)| wb * b)
1615                .sum::<f64>();
1616        for i in 0..n {
1617            let pred = base
1618                + (0..m)
1619                    .map(|j| data[(i, j)] * w[j] * result.beta[j])
1620                    .sum::<f64>();
1621            assert!(
1622                (pred - result.fitted_values[i]).abs() < 1e-9,
1623                "prediction reconstruction mismatch at row {i}: {pred} vs {}",
1624                result.fitted_values[i]
1625            );
1626        }
1627    }
1628
1629    #[test]
1630    fn test_peer_rejects_non_monotonic_argvals() {
1631        let (data, y, t, _) = make_fixture();
1632        let mut bad = t.clone();
1633        bad.swap(0, 1); // first pair now decreasing
1634        let config = PeerConfig::default();
1635        let res = peer(&data, &y, &bad, &config);
1636        assert!(
1637            matches!(res, Err(FdarError::InvalidParameter { .. })),
1638            "non-monotonic argvals must be rejected, got {res:?}"
1639        );
1640    }
1641
1642    #[test]
1643    fn test_peer_rejects_single_observation() {
1644        let m = 5;
1645        let t = uniform_grid(m);
1646        let mut data = FdMatrix::zeros(1, m);
1647        for j in 0..m {
1648            data[(0, j)] = 1.0 + j as f64;
1649        }
1650        let y = vec![1.0];
1651        let res = peer(&data, &y, &t, &PeerConfig::default());
1652        assert!(
1653            matches!(res, Err(FdarError::InvalidDimension { .. })),
1654            "single observation must be rejected, got {res:?}"
1655        );
1656    }
1657
1658    #[test]
1659    fn test_peer_rejects_non_finite_y() {
1660        let (data, mut y, t, _) = make_fixture();
1661        y[0] = f64::NAN;
1662        let res = peer(&data, &y, &t, &PeerConfig::default());
1663        assert!(
1664            matches!(res, Err(FdarError::InvalidParameter { .. })),
1665            "non-finite y must be rejected, got {res:?}"
1666        );
1667    }
1668
1669    // -------------------------------------------------------------------------
1670    // Task 2 tests: GCV grid-search selector
1671    // -------------------------------------------------------------------------
1672
1673    #[test]
1674    fn test_peer_gcv_deterministic() {
1675        let (data, y, t, _) = make_fixture();
1676        let config = PeerConfig {
1677            penalty: PeerPenalty::Difference { order: 2 },
1678            lambda: LambdaChoice::Gcv,
1679        };
1680        let r1 = peer(&data, &y, &t, &config).expect("first GCV call should succeed");
1681        let r2 = peer(&data, &y, &t, &config).expect("second GCV call should succeed");
1682        assert_eq!(
1683            r1.lambda, r2.lambda,
1684            "GCV lambda must be bit-exact across two runs: {} vs {}",
1685            r1.lambda, r2.lambda
1686        );
1687        assert_eq!(r1.lambda_method, LambdaMethod::Gcv);
1688        assert!(r1.gcv.is_some(), "GCV result must have gcv score");
1689    }
1690
1691    #[test]
1692    fn test_peer_gcv_recovers_beta() {
1693        let (data, y, t, true_beta) = make_fixture();
1694        let config = PeerConfig {
1695            penalty: PeerPenalty::Difference { order: 2 },
1696            lambda: LambdaChoice::Gcv,
1697        };
1698        let result = peer(&data, &y, &t, &config).expect("GCV peer() should succeed");
1699
1700        // Non-degenerate lambda: not at extreme ends of the grid
1701        assert!(
1702            result.lambda > 1e-10 && result.lambda < 1e6,
1703            "GCV lambda should be non-degenerate, got {}",
1704            result.lambda
1705        );
1706        // GCV score recorded
1707        assert!(result.gcv.is_some(), "gcv field must be Some for Gcv");
1708        assert_eq!(result.lambda_method, LambdaMethod::Gcv);
1709
1710        // β recovery within tolerance
1711        let max_err = result
1712            .beta
1713            .iter()
1714            .zip(true_beta.iter())
1715            .map(|(a, b)| (a - b).abs())
1716            .fold(0.0_f64, f64::max);
1717        assert!(max_err < 0.15, "GCV beta recovery error: {max_err} >= 0.15");
1718    }
1719
1720    // -------------------------------------------------------------------------
1721    // Task 3 tests: REML EM selector
1722    // -------------------------------------------------------------------------
1723
1724    #[test]
1725    fn test_peer_reml_deterministic() {
1726        let (data, y, t, _) = make_fixture();
1727        let config = PeerConfig {
1728            penalty: PeerPenalty::Difference { order: 2 },
1729            lambda: LambdaChoice::Reml,
1730        };
1731        let r1 = peer(&data, &y, &t, &config).expect("first REML call should succeed");
1732        let r2 = peer(&data, &y, &t, &config).expect("second REML call should succeed");
1733        assert_eq!(
1734            r1.lambda, r2.lambda,
1735            "REML lambda must be bit-exact across two runs: {} vs {}",
1736            r1.lambda, r2.lambda
1737        );
1738        assert_eq!(r1.lambda_method, LambdaMethod::Reml);
1739        assert!(r1.gcv.is_none(), "REML result must have gcv == None");
1740    }
1741
1742    #[test]
1743    fn test_peer_reml_lambda_positive() {
1744        let (data, y, t, _) = make_fixture();
1745        let config = PeerConfig {
1746            penalty: PeerPenalty::Difference { order: 2 },
1747            lambda: LambdaChoice::Reml,
1748        };
1749        let result = peer(&data, &y, &t, &config).expect("REML peer() should succeed");
1750
1751        assert!(
1752            result.lambda > 0.0 && result.lambda.is_finite(),
1753            "REML lambda must be positive and finite, got {}",
1754            result.lambda
1755        );
1756        assert!(result.gcv.is_none(), "gcv must be None for Reml");
1757        assert_eq!(result.lambda_method, LambdaMethod::Reml);
1758    }
1759
1760    #[test]
1761    fn test_peer_reml_gcv_beta_agreement() {
1762        // Use the high-SNR fixture (noise=0.005) for the agreement test.
1763        // REML on this problem correctly identifies a small λ (the range-space
1764        // component of sin(πt) is large, so σ²_u is large, giving λ = σ²_e/σ²_u
1765        // near zero — valid REML behavior for this signal shape).  The resulting
1766        // beta still recovers the truth within 0.15 because at this SNR even
1767        // near-OLS estimates are close.  GCV picks a larger but also reasonable λ.
1768        let (data, y, t, true_beta) = make_fixture(); // noise=0.005, high SNR
1769
1770        let config_gcv = PeerConfig {
1771            penalty: PeerPenalty::Difference { order: 2 },
1772            lambda: LambdaChoice::Gcv,
1773        };
1774        let config_reml = PeerConfig {
1775            penalty: PeerPenalty::Difference { order: 2 },
1776            lambda: LambdaChoice::Reml,
1777        };
1778
1779        let res_gcv = peer(&data, &y, &t, &config_gcv).expect("GCV fit should succeed");
1780        let res_reml = peer(&data, &y, &t, &config_reml).expect("REML fit should succeed");
1781
1782        // REML beta(t) recovers true beta within 0.15
1783        let reml_err = res_reml
1784            .beta
1785            .iter()
1786            .zip(true_beta.iter())
1787            .map(|(a, b)| (a - b).abs())
1788            .fold(0.0_f64, f64::max);
1789        assert!(
1790            reml_err < 0.15,
1791            "REML beta recovery error: {reml_err} >= 0.15"
1792        );
1793
1794        // GCV and REML beta agree within documented tolerance (0.2 max abs diff)
1795        let beta_diff = res_gcv
1796            .beta
1797            .iter()
1798            .zip(res_reml.beta.iter())
1799            .map(|(a, b)| (a - b).abs())
1800            .fold(0.0_f64, f64::max);
1801        assert!(
1802            beta_diff < 0.2,
1803            "REML vs GCV beta disagreement: {beta_diff} >= 0.2 (lambdas: gcv={}, reml={})",
1804            res_gcv.lambda,
1805            res_reml.lambda
1806        );
1807    }
1808
1809    // -------------------------------------------------------------------------
1810    // Task 1 (Phase 68): PeerResult::predict — self-consistency, dim validation,
1811    // and all-finite on fresh curves.
1812    // -------------------------------------------------------------------------
1813
1814    #[test]
1815    fn test_peer_predict_self_consistent() {
1816        // Re-pass training curves → predictions must equal fitted_values within 1e-9.
1817        let (data, y, t, _) = make_fixture();
1818        let config = PeerConfig {
1819            penalty: PeerPenalty::Difference { order: 2 },
1820            lambda: LambdaChoice::Fixed(1e-4),
1821        };
1822        let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1823        let preds = result
1824            .predict(&data, &t)
1825            .expect("predict on training data should succeed");
1826        assert_eq!(preds.len(), result.fitted_values.len());
1827        for (i, (p, f)) in preds.iter().zip(&result.fitted_values).enumerate() {
1828            assert!(
1829                (p - f).abs() < 1e-9,
1830                "predict vs fitted_values mismatch at row {i}: {p} vs {f}"
1831            );
1832        }
1833    }
1834
1835    #[test]
1836    fn test_predict_wrong_ncols() {
1837        // new_data with wrong column count must yield FdarError::InvalidDimension.
1838        let (data, y, t, _) = make_fixture();
1839        let (_, m) = data.shape();
1840        let config = PeerConfig {
1841            penalty: PeerPenalty::Ridge,
1842            lambda: LambdaChoice::Fixed(1e-4),
1843        };
1844        let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1845
1846        // Build a new_data matrix with m-1 columns (wrong ncols)
1847        let mut bad_data = FdMatrix::zeros(5, m - 1);
1848        for i in 0..5 {
1849            for j in 0..(m - 1) {
1850                bad_data[(i, j)] = hash_unit((i * m + j) as u64);
1851            }
1852        }
1853        let err = result
1854            .predict(&bad_data, &t[..m - 1])
1855            .expect_err("wrong ncols should return Err");
1856        assert!(
1857            matches!(err, FdarError::InvalidDimension { .. }),
1858            "expected InvalidDimension, got {err:?}"
1859        );
1860    }
1861
1862    #[test]
1863    fn test_predict_new_curves_finite() {
1864        // Predict on fresh curves (offset by a large constant) — all results finite.
1865        let (data, y, t, _) = make_fixture();
1866        let (n, m) = data.shape();
1867        let config = PeerConfig {
1868            penalty: PeerPenalty::Ridge,
1869            lambda: LambdaChoice::Fixed(1e-4),
1870        };
1871        let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1872
1873        // Build genuinely new curves: large constant shift from training data
1874        let mut new_data = FdMatrix::zeros(n, m);
1875        for i in 0..n {
1876            for j in 0..m {
1877                new_data[(i, j)] = hash_unit((100_000 + i * m + j) as u64) + 10.0;
1878            }
1879        }
1880        let preds = result
1881            .predict(&new_data, &t)
1882            .expect("predict on new curves should succeed");
1883        assert!(
1884            preds.iter().all(|v| v.is_finite()),
1885            "predictions on fresh curves contain non-finite values"
1886        );
1887    }
1888
1889    #[test]
1890    fn test_peer_reml_ridge_and_zeroq_edges() {
1891        let (data, y, t, _) = make_fixture();
1892        let m = t.len();
1893
1894        // Ridge penalty (Q = I_m, null space empty, s=0) — must not panic
1895        let config_ridge = PeerConfig {
1896            penalty: PeerPenalty::Ridge,
1897            lambda: LambdaChoice::Reml,
1898        };
1899        let res_ridge =
1900            peer(&data, &y, &t, &config_ridge).expect("REML with Ridge (s=0) should not panic");
1901        assert!(
1902            res_ridge.lambda > 0.0 && res_ridge.lambda.is_finite(),
1903            "Ridge REML lambda must be positive finite, got {}",
1904            res_ridge.lambda
1905        );
1906
1907        // Zero-Q Decree (all-zero matrix, r=0) — must return documented fallback λ=1e-4
1908        let zero_q = vec![0.0_f64; m * m];
1909        let config_zero = PeerConfig {
1910            penalty: PeerPenalty::Decree(zero_q, m),
1911            lambda: LambdaChoice::Reml,
1912        };
1913        let res_zero =
1914            peer(&data, &y, &t, &config_zero).expect("REML with zero-Q should not panic");
1915        assert!(
1916            (res_zero.lambda - 1e-4).abs() < 1e-15,
1917            "zero-Q REML fallback lambda should be 1e-4, got {}",
1918            res_zero.lambda
1919        );
1920    }
1921
1922    // -------------------------------------------------------------------------
1923    // Task 2 (Phase 68): lpeer() — longitudinal PEER with subject random effects
1924    // -------------------------------------------------------------------------
1925
1926    /// Longitudinal test fixture: n_subjects=20, obs_per=10 (n=200), m=10.
1927    /// True β(t)=sin(πt); injects deterministic between-subject effects at
1928    /// known variance σ²_u_true=1.0.  Returns (data, y, t, subject_map, sigma2_u_true).
1929    ///
1930    /// Uses m=10 so that ncomp = min(n-1, m, 10) = 10 = m, giving full FPC coverage
1931    /// for accurate β(t) back-projection.  n=200 provides reliable REML EM convergence.
1932    fn make_lpeer_fixture() -> (FdMatrix, Vec<f64>, Vec<f64>, Vec<usize>, f64) {
1933        let (n_subjects, obs_per, m) = (20_usize, 10_usize, 10_usize);
1934        let n = n_subjects * obs_per;
1935        let t = uniform_grid(m);
1936        let true_beta: Vec<f64> = t
1937            .iter()
1938            .map(|&ti| (std::f64::consts::PI * ti).sin())
1939            .collect();
1940        let w = simpsons_weights(&t);
1941        let sigma2_u_true = 1.0_f64;
1942
1943        let mut data = FdMatrix::zeros(n, m);
1944        let mut y = vec![0.0_f64; n];
1945        let mut subject_map = vec![0_usize; n];
1946
1947        for s in 0..n_subjects {
1948            // Deterministic subject random effect at scale sqrt(sigma2_u_true)
1949            let u_s = hash_unit(s as u64) * sigma2_u_true.sqrt();
1950            for obs in 0..obs_per {
1951                let i = s * obs_per + obs;
1952                subject_map[i] = s;
1953                for j in 0..m {
1954                    let xi = hash_unit((i * m + j) as u64);
1955                    data[(i, j)] = xi;
1956                    y[i] += xi * true_beta[j] * w[j];
1957                }
1958                y[i] += u_s; // inject between-subject effect
1959                             // small within-subject noise
1960                y[i] += 0.02 * hash_unit(1_000_000 + i as u64);
1961            }
1962        }
1963        (data, y, t, subject_map, sigma2_u_true)
1964    }
1965
1966    #[test]
1967    fn test_lpeer_variance_non_negative() {
1968        let (data, y, t, subject_map, _) = make_lpeer_fixture();
1969        let config = PeerConfig {
1970            penalty: PeerPenalty::Ridge,
1971            lambda: LambdaChoice::Fixed(1e-3),
1972        };
1973        let fit = lpeer(&data, &y, &t, &subject_map, &config)
1974            .expect("lpeer() should succeed on longitudinal fixture");
1975        assert!(
1976            fit.sigma2_subject >= 0.0,
1977            "sigma2_subject must be non-negative, got {}",
1978            fit.sigma2_subject
1979        );
1980        assert!(
1981            fit.sigma2_resid >= 0.0,
1982            "sigma2_resid must be non-negative, got {}",
1983            fit.sigma2_resid
1984        );
1985    }
1986
1987    #[test]
1988    fn test_lpeer_beta_recovery() {
1989        let (data, y, t, subject_map, _) = make_lpeer_fixture();
1990        let m = t.len();
1991        let true_beta: Vec<f64> = t
1992            .iter()
1993            .map(|&ti| (std::f64::consts::PI * ti).sin())
1994            .collect();
1995        let config = PeerConfig {
1996            penalty: PeerPenalty::Ridge,
1997            lambda: LambdaChoice::Fixed(1e-3),
1998        };
1999        let fit = lpeer(&data, &y, &t, &subject_map, &config)
2000            .expect("lpeer() should succeed on longitudinal fixture");
2001        assert_eq!(fit.beta.len(), m);
2002        let max_err = fit
2003            .beta
2004            .iter()
2005            .zip(true_beta.iter())
2006            .map(|(a, b)| (a - b).abs())
2007            .fold(0.0_f64, f64::max);
2008        assert!(
2009            max_err < 0.5,
2010            "lpeer β(t) recovery error vs sin(πt): {max_err} >= 0.5"
2011        );
2012    }
2013
2014    #[test]
2015    fn test_lpeer_lambda_regularizes() {
2016        // λ must genuinely regularize β(t): a heavy structured penalty produces a
2017        // materially different (smoother) coefficient function than a near-zero one.
2018        let (data, y, t, subject_map, _) = make_lpeer_fixture();
2019        let cfg_light = PeerConfig {
2020            penalty: PeerPenalty::Difference { order: 2 },
2021            lambda: LambdaChoice::Fixed(0.0),
2022        };
2023        let cfg_heavy = PeerConfig {
2024            penalty: PeerPenalty::Difference { order: 2 },
2025            lambda: LambdaChoice::Fixed(1e6),
2026        };
2027        let light = lpeer(&data, &y, &t, &subject_map, &cfg_light).expect("light λ fit");
2028        let heavy = lpeer(&data, &y, &t, &subject_map, &cfg_heavy).expect("heavy λ fit");
2029        let max_diff = light
2030            .beta
2031            .iter()
2032            .zip(heavy.beta.iter())
2033            .map(|(a, b)| (a - b).abs())
2034            .fold(0.0_f64, f64::max);
2035        assert!(
2036            max_diff > 1e-3,
2037            "λ does not regularize β(t): max|β(λ=0) − β(λ=1e6)| = {max_diff} <= 1e-3"
2038        );
2039        assert!(heavy.beta.iter().all(|v| v.is_finite()));
2040    }
2041
2042    #[test]
2043    fn test_lpeer_sigma2_tracks_injection() {
2044        let (data, y, t, subject_map, sigma2_u_true) = make_lpeer_fixture();
2045        let config = PeerConfig {
2046            penalty: PeerPenalty::Ridge,
2047            lambda: LambdaChoice::Fixed(1e-3),
2048        };
2049        let fit = lpeer(&data, &y, &t, &subject_map, &config)
2050            .expect("lpeer() should succeed on longitudinal fixture");
2051        // REML EM is consistent but not exact at n=50, 10 subjects — allow factor ~3
2052        assert!(
2053            fit.sigma2_subject > 0.1 && fit.sigma2_subject < 5.0,
2054            "lpeer sigma2_subject={} should track injected {sigma2_u_true} within (0.1, 5.0)",
2055            fit.sigma2_subject
2056        );
2057    }
2058
2059    #[test]
2060    fn test_lpeer_invalid_subject_map() {
2061        let (data, y, t, _, _) = make_lpeer_fixture();
2062        let n = y.len();
2063        // subject_map of wrong length (n-1 instead of n)
2064        let bad_map: Vec<usize> = (0..n - 1).map(|i| i / 5).collect();
2065        let config = PeerConfig {
2066            penalty: PeerPenalty::Ridge,
2067            lambda: LambdaChoice::Fixed(1e-3),
2068        };
2069        let err = lpeer(&data, &y, &t, &bad_map, &config)
2070            .expect_err("wrong-length subject_map should return Err");
2071        assert!(
2072            matches!(err, FdarError::InvalidDimension { .. }),
2073            "expected InvalidDimension, got {err:?}"
2074        );
2075    }
2076
2077    #[test]
2078    fn test_lpeer_single_subject_rejected() {
2079        let (data, y, t, _, _) = make_lpeer_fixture();
2080        let n = y.len();
2081        // All observations from the same subject → n_subjects = 1 (degenerate)
2082        let single_map = vec![0_usize; n];
2083        let config = PeerConfig {
2084            penalty: PeerPenalty::Ridge,
2085            lambda: LambdaChoice::Fixed(1e-3),
2086        };
2087        let err = lpeer(&data, &y, &t, &single_map, &config)
2088            .expect_err("single-subject map should be rejected");
2089        assert!(
2090            matches!(err, FdarError::InvalidParameter { .. }),
2091            "expected InvalidParameter, got {err:?}"
2092        );
2093    }
2094
2095    #[test]
2096    fn test_lpeer_predict_self_consistent() {
2097        // LocalPeerResult::predict on training data must reproduce fitted_values within 1e-9.
2098        let (data, y, t, subject_map, _) = make_lpeer_fixture();
2099        let config = PeerConfig {
2100            penalty: PeerPenalty::Ridge,
2101            lambda: LambdaChoice::Fixed(1e-3),
2102        };
2103        let fit = lpeer(&data, &y, &t, &subject_map, &config)
2104            .expect("lpeer() should succeed on longitudinal fixture");
2105        let preds = fit
2106            .predict(&data, &t)
2107            .expect("LocalPeerResult::predict on training data should succeed");
2108        assert_eq!(preds.len(), fit.fitted_values.len());
2109        for (i, (p, f)) in preds.iter().zip(&fit.fitted_values).enumerate() {
2110            assert!(
2111                (p - f).abs() < 1e-9,
2112                "LocalPeerResult::predict vs fitted_values mismatch at row {i}: {p} vs {f}"
2113            );
2114        }
2115    }
2116
2117    // -------------------------------------------------------------------------
2118    // Task 4 (Phase 68): Crate-root + prelude export reachability compile checks
2119    // -------------------------------------------------------------------------
2120
2121    #[test]
2122    fn test_crate_root_exports_compile() {
2123        // Verify crate-root pub use block brings all 8 PEER symbols into scope.
2124        // In-crate tests use `crate::` paths; the re-export block in lib.rs makes
2125        // them available at the root so external crates can import `fdars_core::{peer, ...}`.
2126        // We test the crate-root by referencing each symbol through the module path
2127        // that lib.rs re-exports from.
2128        use crate::error::FdarError;
2129
2130        // Verify function signatures match expected types (compile-only checks).
2131        let _peer_fn: fn(&FdMatrix, &[f64], &[f64], &PeerConfig) -> Result<PeerResult, FdarError> =
2132            crate::peer::peer;
2133        let _lpeer_fn: fn(
2134            &FdMatrix,
2135            &[f64],
2136            &[f64],
2137            &[usize],
2138            &PeerConfig,
2139        ) -> Result<LocalPeerResult, FdarError> = crate::peer::lpeer;
2140
2141        // Verify enum/struct variants compile
2142        let _lc = LambdaChoice::Fixed(1.0);
2143        let _lm = LambdaMethod::Fixed;
2144        let _pp = PeerPenalty::Ridge;
2145        let _ = PeerConfig {
2146            penalty: _pp,
2147            lambda: _lc,
2148        };
2149        // Verify LocalPeerResult and PeerResult are constructible via the module path
2150        let _ = std::mem::size_of::<PeerResult>();
2151        let _ = std::mem::size_of::<LocalPeerResult>();
2152    }
2153
2154    #[test]
2155    fn test_prelude_exports_compile() {
2156        // Verify the prelude re-exports all 8 PEER symbols.
2157        // `use crate::prelude::*` brings them into this scope.
2158        use crate::error::FdarError;
2159        use crate::prelude::*;
2160
2161        let _peer_fn: fn(&FdMatrix, &[f64], &[f64], &PeerConfig) -> Result<PeerResult, FdarError> =
2162            peer;
2163        let _lpeer_fn: fn(
2164            &FdMatrix,
2165            &[f64],
2166            &[f64],
2167            &[usize],
2168            &PeerConfig,
2169        ) -> Result<LocalPeerResult, FdarError> = lpeer;
2170
2171        let _lc = LambdaChoice::Gcv;
2172        let _lm = LambdaMethod::Gcv;
2173        let _pp = PeerPenalty::Ridge;
2174        let _ = PeerConfig {
2175            penalty: _pp,
2176            lambda: _lc,
2177        };
2178        let _ = std::mem::size_of::<PeerResult>();
2179        let _ = std::mem::size_of::<LocalPeerResult>();
2180    }
2181}