Skip to main content

fdars_core/
famm.rs

1//! Functional Additive Mixed Models (FAMM).
2//!
3//! Implements functional mixed effects models for repeated functional
4//! measurements with subject-level covariates.
5//!
6//! Model: `Y_ij(t) = μ(t) + X_i'β(t) + b_i(t) + ε_ij(t)`
7//!
8//! Key functions:
9//! - [`fmm`] — Fit a functional mixed model via FPC decomposition
10//! - [`fmm_predict`] — Predict curves for new subjects
11//! - [`fmm_test_fixed`] — Hypothesis test on fixed effects
12
13use crate::error::FdarError;
14use crate::iter_maybe_parallel;
15use crate::linalg::{
16    cholesky_factor as linalg_cholesky_factor,
17    cholesky_forward_back as linalg_cholesky_forward_back,
18};
19use crate::matrix::FdMatrix;
20use crate::regression::fdata_to_pc_1d;
21#[cfg(feature = "parallel")]
22use rayon::iter::ParallelIterator;
23
24/// Result of a functional mixed model fit.
25#[derive(Debug, Clone, PartialEq)]
26#[non_exhaustive]
27pub struct FmmResult {
28    /// Overall mean function μ̂(t) (length m)
29    pub mean_function: Vec<f64>,
30    /// Fixed effect coefficient functions β̂_j(t) (p × m matrix, one row per covariate)
31    pub beta_functions: FdMatrix,
32    /// Random effect functions b̂_i(t) per subject (n_subjects × m)
33    pub random_effects: FdMatrix,
34    /// Fitted values for all observations (n_total × m)
35    pub fitted: FdMatrix,
36    /// Residuals (n_total × m)
37    pub residuals: FdMatrix,
38    /// Variance of random effects at each time point (length m)
39    pub random_variance: Vec<f64>,
40    /// Residual variance estimate
41    pub sigma2_eps: f64,
42    /// Random effect variance estimate (per-component)
43    pub sigma2_u: Vec<f64>,
44    /// Number of FPC components used
45    pub ncomp: usize,
46    /// Number of subjects
47    pub n_subjects: usize,
48    /// FPC eigenvalues (singular values squared / n)
49    pub eigenvalues: Vec<f64>,
50}
51
52/// Result of fixed effect hypothesis test.
53#[derive(Debug, Clone, PartialEq)]
54#[non_exhaustive]
55pub struct FmmTestResult {
56    /// F-statistic per covariate (length p)
57    pub f_statistics: Vec<f64>,
58    /// P-values per covariate (via permutation, length p)
59    pub p_values: Vec<f64>,
60}
61
62// ---------------------------------------------------------------------------
63// Core FMM algorithm
64// ---------------------------------------------------------------------------
65
66/// Fit a functional mixed model via FPC decomposition.
67///
68/// # Arguments
69/// * `data` — All observed curves (n_total × m), stacked across subjects and visits
70/// * `subject_ids` — Subject identifier for each curve (length n_total)
71/// * `covariates` — Subject-level covariates (n_total × p).
72///   Each row corresponds to the same curve in `data`.
73///   If a covariate is subject-level, its value should be repeated across visits.
74/// * `ncomp` — Number of FPC components
75///
76/// # Algorithm
77/// 1. Pool curves, compute FPCA
78/// 2. For each FPC score, fit scalar mixed model: ξ_ijk = x_i'γ_k + u_ik + e_ijk
79/// 3. Recover β̂(t) and b̂_i(t) from component coefficients
80///
81/// # Errors
82///
83/// Returns [`FdarError::InvalidDimension`] if `data` is empty (zero rows or
84/// columns), or if `subject_ids.len()` does not match the number of rows.
85/// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero.
86/// Returns [`FdarError::ComputationFailed`] if the underlying FPCA fails.
87#[must_use = "expensive computation whose result should not be discarded"]
88pub fn fmm(
89    data: &FdMatrix,
90    subject_ids: &[usize],
91    covariates: Option<&FdMatrix>,
92    ncomp: usize,
93) -> Result<FmmResult, FdarError> {
94    let n_total = data.nrows();
95    let m = data.ncols();
96    if n_total == 0 || m == 0 {
97        return Err(FdarError::InvalidDimension {
98            parameter: "data",
99            expected: "non-empty matrix".to_string(),
100            actual: format!("{n_total} x {m}"),
101        });
102    }
103    if subject_ids.len() != n_total {
104        return Err(FdarError::InvalidDimension {
105            parameter: "subject_ids",
106            expected: format!("length {n_total}"),
107            actual: format!("length {}", subject_ids.len()),
108        });
109    }
110    if ncomp == 0 {
111        return Err(FdarError::InvalidParameter {
112            parameter: "ncomp",
113            message: "must be >= 1".to_string(),
114        });
115    }
116
117    // Determine unique subjects
118    let (subject_map, n_subjects) = build_subject_map(subject_ids);
119
120    // Step 1: FPCA on pooled data
121    let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect();
122    let fpca = fdata_to_pc_1d(data, ncomp, &argvals)?;
123    let k = fpca.scores.ncols(); // actual number of components
124
125    // Step 2: For each FPC score, fit scalar mixed model (parallelized)
126    let p = covariates.map_or(0, super::matrix::FdMatrix::ncols);
127    let ComponentResults {
128        gamma,
129        u_hat,
130        sigma2_u,
131        sigma2_eps,
132    } = fit_all_components(
133        &fpca.scores,
134        &subject_map,
135        n_subjects,
136        covariates,
137        p,
138        k,
139        n_total,
140        m,
141    );
142
143    // Step 3: Recover functional coefficients (using gamma in original scale)
144    let beta_functions = recover_beta_functions(&gamma, &fpca.rotation, p, m, k);
145    let random_effects = recover_random_effects(&u_hat, &fpca.rotation, n_subjects, m, k);
146
147    // Compute random variance function: Var(b_i(t)) across subjects
148    let random_variance = compute_random_variance(&random_effects, n_subjects, m);
149
150    // Compute fitted and residuals
151    let (fitted, residuals) = compute_fitted_residuals(
152        data,
153        &fpca.mean,
154        &beta_functions,
155        &random_effects,
156        covariates,
157        &subject_map,
158        n_total,
159        m,
160        p,
161    );
162
163    let eigenvalues: Vec<f64> = fpca
164        .singular_values
165        .iter()
166        .map(|&sv| sv * sv / n_total as f64)
167        .collect();
168
169    Ok(FmmResult {
170        mean_function: fpca.mean,
171        beta_functions,
172        random_effects,
173        fitted,
174        residuals,
175        random_variance,
176        sigma2_eps,
177        sigma2_u,
178        ncomp: k,
179        n_subjects,
180        eigenvalues,
181    })
182}
183
184/// Build mapping from observation index to subject index (0..n_subjects-1).
185pub(crate) fn build_subject_map(subject_ids: &[usize]) -> (Vec<usize>, usize) {
186    let mut unique_ids: Vec<usize> = subject_ids.to_vec();
187    unique_ids.sort_unstable();
188    unique_ids.dedup();
189    let n_subjects = unique_ids.len();
190
191    let map: Vec<usize> = subject_ids
192        .iter()
193        .map(|id| unique_ids.iter().position(|u| u == id).unwrap_or(0))
194        .collect();
195
196    (map, n_subjects)
197}
198
199/// Aggregated results from fitting all FPC components.
200struct ComponentResults {
201    gamma: Vec<Vec<f64>>, // gamma[j][k] = fixed effect coeff j for component k
202    u_hat: Vec<Vec<f64>>, // u_hat[i][k] = random effect for subject i, component k
203    sigma2_u: Vec<f64>,   // per-component random effect variance
204    sigma2_eps: f64,      // average residual variance across components
205}
206
207/// Fit scalar mixed models for all FPC components (parallelized across components).
208///
209/// For each component k, scales FPC scores to L²-normalized space, fits a scalar
210/// mixed model, then scales coefficients back to the original score space.
211#[allow(clippy::too_many_arguments)]
212fn fit_all_components(
213    scores: &FdMatrix,
214    subject_map: &[usize],
215    n_subjects: usize,
216    covariates: Option<&FdMatrix>,
217    p: usize,
218    k: usize,
219    n_total: usize,
220    m: usize,
221) -> ComponentResults {
222    // Normalize scores by sqrt(h) to match R's L²-weighted FPCA convention.
223    // This ensures variance components are on the same scale as R's lmer().
224    let h = if m > 1 { 1.0 / (m - 1) as f64 } else { 1.0 };
225    let score_scale = h.sqrt();
226
227    // Fit each component independently — parallelized when the feature is enabled
228    let per_comp: Vec<ScalarMixedResult> = iter_maybe_parallel!(0..k)
229        .map(|comp| {
230            let comp_scores: Vec<f64> = (0..n_total)
231                .map(|i| scores[(i, comp)] * score_scale)
232                .collect();
233            fit_scalar_mixed_model(&comp_scores, subject_map, n_subjects, covariates, p)
234        })
235        .collect();
236
237    // Unpack per-component results into the aggregate structure
238    let mut gamma = vec![vec![0.0; k]; p];
239    let mut u_hat = vec![vec![0.0; k]; n_subjects];
240    let mut sigma2_u = vec![0.0; k];
241    let mut sigma2_eps_total = 0.0;
242
243    for (comp, result) in per_comp.iter().enumerate() {
244        for j in 0..p {
245            gamma[j][comp] = result.gamma[j] / score_scale;
246        }
247        for s in 0..n_subjects {
248            u_hat[s][comp] = result.u_hat[s] / score_scale;
249        }
250        sigma2_u[comp] = result.sigma2_u;
251        sigma2_eps_total += result.sigma2_eps;
252    }
253    let sigma2_eps = sigma2_eps_total / k as f64;
254
255    ComponentResults {
256        gamma,
257        u_hat,
258        sigma2_u,
259        sigma2_eps,
260    }
261}
262
263/// Scalar mixed model result for one FPC component.
264pub(crate) struct ScalarMixedResult {
265    pub(crate) gamma: Vec<f64>, // fixed effects (length p)
266    pub(crate) u_hat: Vec<f64>, // random effects per subject (length n_subjects)
267    pub(crate) sigma2_u: f64,   // random effect variance
268    pub(crate) sigma2_eps: f64, // residual variance
269}
270
271/// Precomputed subject structure for the mixed model.
272pub(crate) struct SubjectStructure {
273    pub(crate) counts: Vec<usize>,
274    pub(crate) obs: Vec<Vec<usize>>,
275}
276
277impl SubjectStructure {
278    pub(crate) fn new(subject_map: &[usize], n_subjects: usize, n: usize) -> Self {
279        let mut counts = vec![0usize; n_subjects];
280        let mut obs: Vec<Vec<usize>> = vec![Vec::new(); n_subjects];
281        for i in 0..n {
282            let s = subject_map[i];
283            counts[s] += 1;
284            obs[s].push(i);
285        }
286        Self { counts, obs }
287    }
288}
289
290/// Compute shrinkage weights: w_s = σ²_u / (σ²_u + σ²_e / n_s).
291fn shrinkage_weights(ss: &SubjectStructure, sigma2_u: f64, sigma2_e: f64) -> Vec<f64> {
292    ss.counts
293        .iter()
294        .map(|&c| {
295            let ns = c as f64;
296            if ns < 1.0 {
297                0.0
298            } else {
299                sigma2_u / (sigma2_u + sigma2_e / ns)
300            }
301        })
302        .collect()
303}
304
305/// GLS fixed effect update using block-diagonal V^{-1}.
306///
307/// Computes γ = (X'V⁻¹X)⁻¹ X'V⁻¹y exploiting the balanced random intercept structure.
308fn gls_update_gamma(
309    cov: &FdMatrix,
310    p: usize,
311    ss: &SubjectStructure,
312    weights: &[f64],
313    y: &[f64],
314    sigma2_e: f64,
315) -> Option<Vec<f64>> {
316    let n_subjects = ss.counts.len();
317    let mut xtvinvx = vec![0.0; p * p];
318    let mut xtvinvy = vec![0.0; p];
319    let inv_e = 1.0 / sigma2_e;
320
321    for s in 0..n_subjects {
322        let ns = ss.counts[s] as f64;
323        if ns < 1.0 {
324            continue;
325        }
326        let (x_sum, y_sum) = subject_sums(cov, y, &ss.obs[s], p);
327        accumulate_gls_terms(
328            cov,
329            y,
330            &ss.obs[s],
331            &x_sum,
332            y_sum,
333            weights[s],
334            ns,
335            inv_e,
336            p,
337            &mut xtvinvx,
338            &mut xtvinvy,
339        );
340    }
341
342    for j in 0..p {
343        xtvinvx[j * p + j] += 1e-10;
344    }
345    cholesky_solve(&xtvinvx, &xtvinvy, p)
346}
347
348/// Compute subject-level covariate sums and response sum.
349fn subject_sums(cov: &FdMatrix, y: &[f64], obs: &[usize], p: usize) -> (Vec<f64>, f64) {
350    let mut x_sum = vec![0.0; p];
351    let mut y_sum = 0.0;
352    for &i in obs {
353        for r in 0..p {
354            x_sum[r] += cov[(i, r)];
355        }
356        y_sum += y[i];
357    }
358    (x_sum, y_sum)
359}
360
361/// Accumulate X'V^{-1}X and X'V^{-1}y for one subject.
362fn accumulate_gls_terms(
363    cov: &FdMatrix,
364    y: &[f64],
365    obs: &[usize],
366    x_sum: &[f64],
367    y_sum: f64,
368    w_s: f64,
369    ns: f64,
370    inv_e: f64,
371    p: usize,
372    xtvinvx: &mut [f64],
373    xtvinvy: &mut [f64],
374) {
375    for &i in obs {
376        let vinv_y = inv_e * (y[i] - w_s * y_sum / ns);
377        for r in 0..p {
378            xtvinvy[r] += cov[(i, r)] * vinv_y;
379            for c in r..p {
380                let vinv_xc = inv_e * (cov[(i, c)] - w_s * x_sum[c] / ns);
381                let val = cov[(i, r)] * vinv_xc;
382                xtvinvx[r * p + c] += val;
383                if r != c {
384                    xtvinvx[c * p + r] += val;
385                }
386            }
387        }
388    }
389}
390
391/// REML EM update for variance components.
392///
393/// Returns (σ²_u_new, σ²_e_new) from the conditional expectations.
394/// Uses n - p divisor for σ²_e (REML correction where p = number of fixed effects).
395fn reml_variance_update(
396    residuals: &[f64],
397    ss: &SubjectStructure,
398    weights: &[f64],
399    sigma2_u: f64,
400    p: usize,
401) -> (f64, f64) {
402    let n_subjects = ss.counts.len();
403    let n: usize = ss.counts.iter().sum();
404    let mut sigma2_u_new = 0.0;
405    let mut sigma2_e_new = 0.0;
406
407    for s in 0..n_subjects {
408        let ns = ss.counts[s] as f64;
409        if ns < 1.0 {
410            continue;
411        }
412        let w_s = weights[s];
413        let mean_r_s: f64 = ss.obs[s].iter().map(|&i| residuals[i]).sum::<f64>() / ns;
414        let u_hat_s = w_s * mean_r_s;
415        let cond_var_s = sigma2_u * (1.0 - w_s);
416
417        sigma2_u_new += u_hat_s * u_hat_s + cond_var_s;
418        for &i in &ss.obs[s] {
419            sigma2_e_new += (residuals[i] - u_hat_s).powi(2);
420        }
421        sigma2_e_new += ns * cond_var_s;
422    }
423
424    // REML divisor: n - p for residual variance (matches R's lmer)
425    let denom_e = (n.saturating_sub(p)).max(1) as f64;
426
427    (
428        (sigma2_u_new / n_subjects as f64).max(1e-15),
429        (sigma2_e_new / denom_e).max(1e-15),
430    )
431}
432
433/// Fit scalar mixed model: y_ij = x_i'γ + u_i + e_ij.
434///
435/// Uses iterative GLS for fixed effects + REML EM for variance components,
436/// matching R's lmer() behavior. Initializes from Henderson's ANOVA, then
437/// iterates until convergence.
438pub(crate) fn fit_scalar_mixed_model(
439    y: &[f64],
440    subject_map: &[usize],
441    n_subjects: usize,
442    covariates: Option<&FdMatrix>,
443    p: usize,
444) -> ScalarMixedResult {
445    let n = y.len();
446    let ss = SubjectStructure::new(subject_map, n_subjects, n);
447
448    // Initialize from OLS + Henderson's ANOVA
449    let gamma_init = estimate_fixed_effects(y, covariates, p, n);
450    let residuals_init = compute_ols_residuals(y, covariates, &gamma_init, p, n);
451    let (mut sigma2_u, mut sigma2_e) =
452        estimate_variance_components(&residuals_init, subject_map, n_subjects, n);
453
454    if sigma2_e < 1e-15 {
455        sigma2_e = 1e-6;
456    }
457    if sigma2_u < 1e-15 {
458        sigma2_u = sigma2_e * 0.1;
459    }
460
461    let mut gamma = gamma_init;
462
463    for _iter in 0..50 {
464        let sigma2_u_old = sigma2_u;
465        let sigma2_e_old = sigma2_e;
466
467        let weights = shrinkage_weights(&ss, sigma2_u, sigma2_e);
468
469        if let Some(cov) = covariates.filter(|_| p > 0) {
470            if let Some(g) = gls_update_gamma(cov, p, &ss, &weights, y, sigma2_e) {
471                gamma = g;
472            }
473        }
474
475        let r = compute_ols_residuals(y, covariates, &gamma, p, n);
476        (sigma2_u, sigma2_e) = reml_variance_update(&r, &ss, &weights, sigma2_u, p);
477
478        let delta = (sigma2_u - sigma2_u_old).abs() + (sigma2_e - sigma2_e_old).abs();
479        if delta < 1e-10 * (sigma2_u_old + sigma2_e_old) {
480            break;
481        }
482    }
483
484    let final_residuals = compute_ols_residuals(y, covariates, &gamma, p, n);
485    let u_hat = compute_blup(
486        &final_residuals,
487        subject_map,
488        n_subjects,
489        sigma2_u,
490        sigma2_e,
491    );
492
493    ScalarMixedResult {
494        gamma,
495        u_hat,
496        sigma2_u,
497        sigma2_eps: sigma2_e,
498    }
499}
500
501/// OLS estimation of fixed effects.
502fn estimate_fixed_effects(
503    y: &[f64],
504    covariates: Option<&FdMatrix>,
505    p: usize,
506    n: usize,
507) -> Vec<f64> {
508    if p == 0 || covariates.is_none() {
509        return Vec::new();
510    }
511    let cov = covariates.expect("checked: covariates is Some");
512
513    // Solve (X'X)γ = X'y via Cholesky
514    let mut xtx = vec![0.0; p * p];
515    let mut xty = vec![0.0; p];
516    for i in 0..n {
517        for r in 0..p {
518            xty[r] += cov[(i, r)] * y[i];
519            for s in r..p {
520                let val = cov[(i, r)] * cov[(i, s)];
521                xtx[r * p + s] += val;
522                if r != s {
523                    xtx[s * p + r] += val;
524                }
525            }
526        }
527    }
528    // Regularize
529    for j in 0..p {
530        xtx[j * p + j] += 1e-8;
531    }
532
533    cholesky_solve(&xtx, &xty, p).unwrap_or(vec![0.0; p])
534}
535
536/// Cholesky solve: A x = b where A is p-by-p symmetric positive definite.
537/// Returns `None` if the matrix is singular.
538fn cholesky_solve(a: &[f64], b: &[f64], p: usize) -> Option<Vec<f64>> {
539    let l = linalg_cholesky_factor(a, p).ok()?;
540    Some(linalg_cholesky_forward_back(&l, b, p))
541}
542
543/// Compute OLS residuals: r = y - X*gamma.
544fn compute_ols_residuals(
545    y: &[f64],
546    covariates: Option<&FdMatrix>,
547    gamma: &[f64],
548    p: usize,
549    n: usize,
550) -> Vec<f64> {
551    let mut residuals = y.to_vec();
552    if p > 0 {
553        if let Some(cov) = covariates {
554            for i in 0..n {
555                for j in 0..p {
556                    residuals[i] -= cov[(i, j)] * gamma[j];
557                }
558            }
559        }
560    }
561    residuals
562}
563
564/// Estimate variance components via method of moments.
565///
566/// σ²_u and σ²_ε from one-way random effects ANOVA.
567fn estimate_variance_components(
568    residuals: &[f64],
569    subject_map: &[usize],
570    n_subjects: usize,
571    n: usize,
572) -> (f64, f64) {
573    // Compute subject means and within-subject SS
574    let mut subject_sums = vec![0.0; n_subjects];
575    let mut subject_counts = vec![0usize; n_subjects];
576    for i in 0..n {
577        let s = subject_map[i];
578        subject_sums[s] += residuals[i];
579        subject_counts[s] += 1;
580    }
581    let subject_means: Vec<f64> = subject_sums
582        .iter()
583        .zip(&subject_counts)
584        .map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 })
585        .collect();
586
587    // Within-subject SS
588    let mut ss_within = 0.0;
589    for i in 0..n {
590        let s = subject_map[i];
591        ss_within += (residuals[i] - subject_means[s]).powi(2);
592    }
593    let df_within = n.saturating_sub(n_subjects);
594
595    // Between-subject SS
596    let grand_mean = residuals.iter().sum::<f64>() / n as f64;
597    let mut ss_between = 0.0;
598    for s in 0..n_subjects {
599        ss_between += subject_counts[s] as f64 * (subject_means[s] - grand_mean).powi(2);
600    }
601
602    let sigma2_eps = if df_within > 0 {
603        ss_within / df_within as f64
604    } else {
605        1e-6
606    };
607
608    // Mean number of observations per subject
609    let n_bar = n as f64 / n_subjects.max(1) as f64;
610    let df_between = n_subjects.saturating_sub(1).max(1);
611    let ms_between = ss_between / df_between as f64;
612    let sigma2_u = ((ms_between - sigma2_eps) / n_bar).max(0.0);
613
614    (sigma2_u, sigma2_eps)
615}
616
617/// Compute BLUP (Best Linear Unbiased Prediction) for random effects.
618///
619/// û_i = σ²_u / (σ²_u + σ²_ε/n_i) * (ȳ_i - x̄_i'γ)
620fn compute_blup(
621    residuals: &[f64],
622    subject_map: &[usize],
623    n_subjects: usize,
624    sigma2_u: f64,
625    sigma2_eps: f64,
626) -> Vec<f64> {
627    let mut subject_sums = vec![0.0; n_subjects];
628    let mut subject_counts = vec![0usize; n_subjects];
629    for (i, &r) in residuals.iter().enumerate() {
630        let s = subject_map[i];
631        subject_sums[s] += r;
632        subject_counts[s] += 1;
633    }
634
635    (0..n_subjects)
636        .map(|s| {
637            let ni = subject_counts[s] as f64;
638            if ni < 1.0 {
639                return 0.0;
640            }
641            let mean_r = subject_sums[s] / ni;
642            let shrinkage = sigma2_u / (sigma2_u + sigma2_eps / ni).max(1e-15);
643            shrinkage * mean_r
644        })
645        .collect()
646}
647
648// ---------------------------------------------------------------------------
649// Recovery of functional coefficients
650// ---------------------------------------------------------------------------
651
652/// Recover β̂(t) = Σ_k γ̂_jk φ_k(t) for each covariate j.
653fn recover_beta_functions(
654    gamma: &[Vec<f64>],
655    rotation: &FdMatrix,
656    p: usize,
657    m: usize,
658    k: usize,
659) -> FdMatrix {
660    let mut beta = FdMatrix::zeros(p, m);
661    for j in 0..p {
662        for t in 0..m {
663            let mut val = 0.0;
664            for comp in 0..k {
665                val += gamma[j][comp] * rotation[(t, comp)];
666            }
667            beta[(j, t)] = val;
668        }
669    }
670    beta
671}
672
673/// Recover b̂_i(t) = Σ_k û_ik φ_k(t) for each subject i.
674pub(crate) fn recover_random_effects(
675    u_hat: &[Vec<f64>],
676    rotation: &FdMatrix,
677    n_subjects: usize,
678    m: usize,
679    k: usize,
680) -> FdMatrix {
681    let mut re = FdMatrix::zeros(n_subjects, m);
682    for s in 0..n_subjects {
683        for t in 0..m {
684            let mut val = 0.0;
685            for comp in 0..k {
686                val += u_hat[s][comp] * rotation[(t, comp)];
687            }
688            re[(s, t)] = val;
689        }
690    }
691    re
692}
693
694/// Compute random effect variance function: Var_i(b̂_i(t)).
695fn compute_random_variance(random_effects: &FdMatrix, n_subjects: usize, m: usize) -> Vec<f64> {
696    (0..m)
697        .map(|t| {
698            let mean: f64 =
699                (0..n_subjects).map(|s| random_effects[(s, t)]).sum::<f64>() / n_subjects as f64;
700            let var: f64 = (0..n_subjects)
701                .map(|s| (random_effects[(s, t)] - mean).powi(2))
702                .sum::<f64>()
703                / n_subjects.max(1) as f64;
704            var
705        })
706        .collect()
707}
708
709/// Compute fitted values and residuals.
710fn compute_fitted_residuals(
711    data: &FdMatrix,
712    mean_function: &[f64],
713    beta_functions: &FdMatrix,
714    random_effects: &FdMatrix,
715    covariates: Option<&FdMatrix>,
716    subject_map: &[usize],
717    n_total: usize,
718    m: usize,
719    p: usize,
720) -> (FdMatrix, FdMatrix) {
721    let mut fitted = FdMatrix::zeros(n_total, m);
722    let mut residuals = FdMatrix::zeros(n_total, m);
723
724    for i in 0..n_total {
725        let s = subject_map[i];
726        for t in 0..m {
727            let mut val = mean_function[t] + random_effects[(s, t)];
728            if p > 0 {
729                if let Some(cov) = covariates {
730                    for j in 0..p {
731                        val += cov[(i, j)] * beta_functions[(j, t)];
732                    }
733                }
734            }
735            fitted[(i, t)] = val;
736            residuals[(i, t)] = data[(i, t)] - val;
737        }
738    }
739
740    (fitted, residuals)
741}
742
743// ---------------------------------------------------------------------------
744// Prediction
745// ---------------------------------------------------------------------------
746
747/// Predict curves for new subjects.
748///
749/// # Arguments
750/// * `result` — Fitted FMM result
751/// * `new_covariates` — Covariates for new subjects (n_new × p)
752///
753/// Returns predicted curves (n_new × m) using only fixed effects (no random effects for new subjects).
754#[must_use = "prediction result should not be discarded"]
755pub fn fmm_predict(result: &FmmResult, new_covariates: Option<&FdMatrix>) -> FdMatrix {
756    let m = result.mean_function.len();
757    let n_new = new_covariates.map_or(1, super::matrix::FdMatrix::nrows);
758    let p = result.beta_functions.nrows();
759
760    let mut predicted = FdMatrix::zeros(n_new, m);
761    for i in 0..n_new {
762        for t in 0..m {
763            let mut val = result.mean_function[t];
764            if let Some(cov) = new_covariates {
765                for j in 0..p {
766                    val += cov[(i, j)] * result.beta_functions[(j, t)];
767                }
768            }
769            predicted[(i, t)] = val;
770        }
771    }
772    predicted
773}
774
775// ---------------------------------------------------------------------------
776// Hypothesis testing
777// ---------------------------------------------------------------------------
778
779/// Permutation test for fixed effects in functional mixed model.
780///
781/// Tests H₀: β_j(t) = 0 for each covariate j.
782/// Uses integrated squared norm as test statistic: T_j = ∫ β̂_j(t)² dt.
783///
784/// # Arguments
785/// * `data` — All observed curves (n_total × m)
786/// * `subject_ids` — Subject identifiers
787/// * `covariates` — Subject-level covariates (n_total × p)
788/// * `ncomp` — Number of FPC components
789/// * `n_perm` — Number of permutations
790/// * `seed` — Random seed
791///
792/// # Errors
793///
794/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows, or
795/// `covariates` has zero columns.
796/// Propagates errors from [`fmm`] (e.g., dimension mismatches or FPCA failure).
797#[must_use = "expensive computation whose result should not be discarded"]
798pub fn fmm_test_fixed(
799    data: &FdMatrix,
800    subject_ids: &[usize],
801    covariates: &FdMatrix,
802    ncomp: usize,
803    n_perm: usize,
804    seed: u64,
805) -> Result<FmmTestResult, FdarError> {
806    let n_total = data.nrows();
807    let m = data.ncols();
808    let p = covariates.ncols();
809    if n_total == 0 {
810        return Err(FdarError::InvalidDimension {
811            parameter: "data",
812            expected: "non-empty matrix".to_string(),
813            actual: format!("{n_total} rows"),
814        });
815    }
816    if p == 0 {
817        return Err(FdarError::InvalidDimension {
818            parameter: "covariates",
819            expected: "at least 1 column".to_string(),
820            actual: "0 columns".to_string(),
821        });
822    }
823
824    // Fit observed model
825    let result = fmm(data, subject_ids, Some(covariates), ncomp)?;
826
827    // Observed test statistics: ∫ β̂_j(t)² dt for each covariate
828    let observed_stats = compute_integrated_beta_sq(&result.beta_functions, p, m);
829
830    // Permutation test
831    let (f_statistics, p_values) = permutation_test(
832        data,
833        subject_ids,
834        covariates,
835        ncomp,
836        n_perm,
837        seed,
838        &observed_stats,
839        p,
840        m,
841    );
842
843    Ok(FmmTestResult {
844        f_statistics,
845        p_values,
846    })
847}
848
849/// Compute ∫ β̂_j(t)² dt for each covariate.
850fn compute_integrated_beta_sq(beta: &FdMatrix, p: usize, m: usize) -> Vec<f64> {
851    let h = if m > 1 { 1.0 / (m - 1) as f64 } else { 1.0 };
852    (0..p)
853        .map(|j| {
854            let ss: f64 = (0..m).map(|t| beta[(j, t)].powi(2)).sum();
855            ss * h
856        })
857        .collect()
858}
859
860/// Run permutation test for fixed effects.
861fn permutation_test(
862    data: &FdMatrix,
863    subject_ids: &[usize],
864    covariates: &FdMatrix,
865    ncomp: usize,
866    n_perm: usize,
867    seed: u64,
868    observed_stats: &[f64],
869    p: usize,
870    m: usize,
871) -> (Vec<f64>, Vec<f64>) {
872    use rand::prelude::*;
873    let n_total = data.nrows();
874    // NOT migrated to permutation_test::permutation_pvalue — uses a single ADVANCING StdRng (per-perm
875    // reseed would change the p-values) AND is multi-statistic (per-covariate n_ge[j]); the `-> f64`
876    // scaffold cannot express a per-covariate count (Phase-49 CONS-02 Plan A).
877    let mut rng = StdRng::seed_from_u64(seed);
878    let mut n_ge = vec![0usize; p];
879
880    for _ in 0..n_perm {
881        // Permute covariates across subjects
882        let mut perm_indices: Vec<usize> = (0..n_total).collect();
883        perm_indices.shuffle(&mut rng);
884        let perm_cov = permute_rows(covariates, &perm_indices);
885
886        if let Ok(perm_result) = fmm(data, subject_ids, Some(&perm_cov), ncomp) {
887            let perm_stats = compute_integrated_beta_sq(&perm_result.beta_functions, p, m);
888            for j in 0..p {
889                if perm_stats[j] >= observed_stats[j] {
890                    n_ge[j] += 1;
891                }
892            }
893        }
894    }
895
896    let p_values: Vec<f64> = n_ge
897        .iter()
898        .map(|&count| (count + 1) as f64 / (n_perm + 1) as f64)
899        .collect();
900    let f_statistics = observed_stats.to_vec();
901
902    (f_statistics, p_values)
903}
904
905/// Permute rows of a matrix according to given indices.
906fn permute_rows(mat: &FdMatrix, indices: &[usize]) -> FdMatrix {
907    let n = indices.len();
908    let m = mat.ncols();
909    let mut result = FdMatrix::zeros(n, m);
910    for (new_i, &old_i) in indices.iter().enumerate() {
911        for j in 0..m {
912            result[(new_i, j)] = mat[(old_i, j)];
913        }
914    }
915    result
916}
917
918// ---------------------------------------------------------------------------
919// denseFLMM — dense functional linear mixed model
920// ---------------------------------------------------------------------------
921
922/// Configuration for [`dense_flmm`].
923///
924/// No `#[non_exhaustive]` — callers may use struct-literal construction.
925#[derive(Debug, Clone, PartialEq)]
926#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
927pub struct DenseFlmmConfig {
928    /// Number of FPC components (default: 3)
929    pub ncomp: usize,
930    /// Maximum REML EM iterations per component model (default: 50)
931    pub max_iter: usize,
932    /// Relative convergence tolerance for variance components (default: 1e-10)
933    pub tol: f64,
934    /// Include random slopes in addition to random intercepts (default: false).
935    ///
936    /// When `false`, only random intercepts are estimated.
937    /// Random-slope estimation is **not yet implemented**; setting this to `true`
938    /// returns [`FdarError::InvalidParameter`] until the feature ships.
939    /// `sigma2_slope` is always zero-filled in this release.
940    pub random_slopes: bool,
941}
942
943impl Default for DenseFlmmConfig {
944    fn default() -> Self {
945        Self {
946            ncomp: 3,
947            max_iter: 50,
948            tol: 1e-10,
949            random_slopes: false,
950        }
951    }
952}
953
954/// Result of a dense functional linear mixed model fit.
955///
956/// # Parametrization note
957///
958/// fdars formulates the model over FPC scores (reusing `fdata_to_pc_1d`) rather than
959/// over spline/basis coefficients as in R's `denseFLMM` package. Consequently
960/// variance components are per FPC component, not smoothed over the argument domain.
961///
962/// Random-slope estimation is not implemented in this release; `sigma2_slope` is
963/// always zero-filled (one entry per FPC component). Future releases may add
964/// two-random-effect scalar LMM support via a dedicated helper.
965#[derive(Debug, Clone, PartialEq)]
966#[non_exhaustive]
967#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
968pub struct DenseFlmmResult {
969    /// Overall mean function μ̂(t) (length m)
970    pub mean_function: Vec<f64>,
971    /// Fixed effect coefficient functions β̂_j(t) (p × m matrix, one row per covariate)
972    pub beta_functions: FdMatrix,
973    /// Random effect functions b̂_i(t) per subject (n_subjects × m)
974    pub random_effects: FdMatrix,
975    /// Fitted values for all observations (n_total × m)
976    pub fitted: FdMatrix,
977    /// Residuals (n_total × m)
978    pub residuals: FdMatrix,
979    /// Variance of random effects at each time point Var_i(b̂_i(t)) (length m)
980    pub random_variance: Vec<f64>,
981    /// Mean residual variance averaged across FPC-score component models.
982    ///
983    /// Each per-component model operates on L²-normalized scores; this average
984    /// is on the normalized scale and is not directly comparable to the marginal
985    /// residual variance `σ²_ε` from R's `lmer()`. See the struct-level
986    /// parametrization note.
987    pub sigma2_eps: f64,
988    /// Random-intercept variance per FPC component (length k)
989    pub sigma2_u: Vec<f64>,
990    /// Random-slope variance per FPC component (zero-filled when `random_slopes = false`)
991    pub sigma2_slope: Vec<f64>,
992    /// Number of FPC components actually used
993    pub ncomp: usize,
994    /// Number of unique subjects
995    pub n_subjects: usize,
996    /// FPC eigenvalues (singular values squared / n_total), length k
997    pub eigenvalues: Vec<f64>,
998    /// Maximum number of REML EM iterations reached across components
999    pub n_iter: usize,
1000    /// `true` if all component models converged before `config.max_iter`
1001    pub converged: bool,
1002}
1003
1004/// Fit a dense functional linear mixed model via FPC score decomposition.
1005///
1006/// Extends [`fmm`] with REML convergence metadata and the `DenseFlmmConfig`
1007/// struct interface.
1008///
1009/// # Parametrization divergence from R's `denseFLMM`
1010///
1011/// R's `denseFLMM` estimates eigenfunctions from raw covariance smoothing
1012/// (gamm/bam REML over basis coefficients). fdars decomposes curves into
1013/// FPC scores via `fdata_to_pc_1d`, then fits a per-component scalar mixed
1014/// model — producing equivalent fixed-effect and random-effect functions but
1015/// without the covariance-smoothing regularization step.
1016///
1017/// # Arguments
1018///
1019/// * `data` — All observed curves (n_total × m)
1020/// * `subject_ids` — Subject identifier for each curve (length n_total)
1021/// * `covariates` — Subject-level covariates (n_total × p), or `None`
1022/// * `config` — Algorithm configuration
1023///
1024/// # Errors
1025///
1026/// Returns [`FdarError::InvalidDimension`] if `data` is empty or `subject_ids`
1027/// length mismatches `data.nrows()`.
1028/// Returns [`FdarError::InvalidParameter`] if `config.ncomp` is zero.
1029/// Returns [`FdarError::ComputationFailed`] if the underlying FPCA fails.
1030///
1031/// # Example
1032///
1033/// ```rust
1034/// use fdars_core::famm::{DenseFlmmConfig, dense_flmm};
1035/// let mut cfg = DenseFlmmConfig::default();
1036/// cfg.ncomp = 2;
1037/// ```
1038#[must_use = "expensive computation whose result should not be discarded"]
1039pub fn dense_flmm(
1040    data: &FdMatrix,
1041    subject_ids: &[usize],
1042    covariates: Option<&FdMatrix>,
1043    config: &DenseFlmmConfig,
1044) -> Result<DenseFlmmResult, FdarError> {
1045    let n_total = data.nrows();
1046    let m = data.ncols();
1047    if n_total == 0 || m == 0 {
1048        return Err(FdarError::InvalidDimension {
1049            parameter: "data",
1050            expected: "non-empty matrix".to_string(),
1051            actual: format!("{n_total} x {m}"),
1052        });
1053    }
1054    if subject_ids.len() != n_total {
1055        return Err(FdarError::InvalidDimension {
1056            parameter: "subject_ids",
1057            expected: format!("length {n_total}"),
1058            actual: format!("length {}", subject_ids.len()),
1059        });
1060    }
1061    if config.ncomp == 0 {
1062        return Err(FdarError::InvalidParameter {
1063            parameter: "ncomp",
1064            message: "must be >= 1".to_string(),
1065        });
1066    }
1067    if config.max_iter == 0 {
1068        return Err(FdarError::InvalidParameter {
1069            parameter: "max_iter",
1070            message: "must be >= 1".to_string(),
1071        });
1072    }
1073    if config.random_slopes {
1074        return Err(FdarError::InvalidParameter {
1075            parameter: "random_slopes",
1076            message: "random slope estimation is not yet implemented; \
1077                      use random_slopes: false"
1078                .to_string(),
1079        });
1080    }
1081
1082    let (subject_map, n_subjects) = build_subject_map(subject_ids);
1083
1084    let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect();
1085    let fpca = fdata_to_pc_1d(data, config.ncomp, &argvals)?;
1086    let k = fpca.scores.ncols();
1087
1088    let p = covariates.map_or(0, super::matrix::FdMatrix::ncols);
1089
1090    // Scale scores as in fit_all_components
1091    let h = if m > 1 { 1.0 / (m - 1) as f64 } else { 1.0 };
1092    let score_scale = h.sqrt();
1093
1094    // Fit each component with convergence tracking
1095    let per_comp: Vec<ScalarMixedResultWithMeta> = iter_maybe_parallel!(0..k)
1096        .map(|comp| {
1097            let comp_scores: Vec<f64> = (0..n_total)
1098                .map(|i| fpca.scores[(i, comp)] * score_scale)
1099                .collect();
1100            fit_scalar_mixed_model_tracked(
1101                &comp_scores,
1102                &subject_map,
1103                n_subjects,
1104                covariates,
1105                p,
1106                config.max_iter,
1107                config.tol,
1108            )
1109        })
1110        .collect();
1111
1112    // Unpack per-component results
1113    let mut gamma = vec![vec![0.0; k]; p];
1114    let mut u_hat = vec![vec![0.0; k]; n_subjects];
1115    let mut sigma2_u = vec![0.0; k];
1116    let mut sigma2_eps_total = 0.0;
1117    let mut all_converged = true;
1118    let mut max_n_iter = 0usize;
1119
1120    for (comp, r) in per_comp.iter().enumerate() {
1121        for j in 0..p {
1122            gamma[j][comp] = r.result.gamma[j] / score_scale;
1123        }
1124        for s in 0..n_subjects {
1125            u_hat[s][comp] = r.result.u_hat[s] / score_scale;
1126        }
1127        sigma2_u[comp] = r.result.sigma2_u;
1128        sigma2_eps_total += r.result.sigma2_eps;
1129        if !r.converged {
1130            all_converged = false;
1131        }
1132        if r.n_iter > max_n_iter {
1133            max_n_iter = r.n_iter;
1134        }
1135    }
1136    let sigma2_eps = if k > 0 {
1137        sigma2_eps_total / k as f64
1138    } else {
1139        0.0
1140    };
1141
1142    let beta_functions = recover_beta_functions(&gamma, &fpca.rotation, p, m, k);
1143    let random_effects = recover_random_effects(&u_hat, &fpca.rotation, n_subjects, m, k);
1144    let random_variance = compute_random_variance(&random_effects, n_subjects, m);
1145
1146    let (fitted, residuals) = compute_fitted_residuals(
1147        data,
1148        &fpca.mean,
1149        &beta_functions,
1150        &random_effects,
1151        covariates,
1152        &subject_map,
1153        n_total,
1154        m,
1155        p,
1156    );
1157
1158    let eigenvalues: Vec<f64> = fpca
1159        .singular_values
1160        .iter()
1161        .map(|&sv| sv * sv / n_total as f64)
1162        .collect();
1163
1164    // sigma2_slope is always zero-filled (random-slope estimation not yet implemented)
1165    let sigma2_slope = vec![0.0; k];
1166
1167    Ok(DenseFlmmResult {
1168        mean_function: fpca.mean,
1169        beta_functions,
1170        random_effects,
1171        fitted,
1172        residuals,
1173        random_variance,
1174        sigma2_eps,
1175        sigma2_u,
1176        sigma2_slope,
1177        ncomp: k,
1178        n_subjects,
1179        eigenvalues,
1180        n_iter: max_n_iter,
1181        converged: all_converged,
1182    })
1183}
1184
1185/// Internal: scalar mixed model result with convergence metadata.
1186struct ScalarMixedResultWithMeta {
1187    result: ScalarMixedResult,
1188    n_iter: usize,
1189    converged: bool,
1190}
1191
1192/// Fit scalar mixed model, tracking convergence and iteration count.
1193fn fit_scalar_mixed_model_tracked(
1194    y: &[f64],
1195    subject_map: &[usize],
1196    n_subjects: usize,
1197    covariates: Option<&FdMatrix>,
1198    p: usize,
1199    max_iter: usize,
1200    tol: f64,
1201) -> ScalarMixedResultWithMeta {
1202    let n = y.len();
1203    let ss = SubjectStructure::new(subject_map, n_subjects, n);
1204
1205    let gamma_init = estimate_fixed_effects(y, covariates, p, n);
1206    let residuals_init = compute_ols_residuals(y, covariates, &gamma_init, p, n);
1207    let (mut sigma2_u, mut sigma2_e) =
1208        estimate_variance_components(&residuals_init, subject_map, n_subjects, n);
1209
1210    if sigma2_e < 1e-15 {
1211        sigma2_e = 1e-6;
1212    }
1213    if sigma2_u < 1e-15 {
1214        sigma2_u = sigma2_e * 0.1;
1215    }
1216
1217    let mut gamma = gamma_init;
1218    let mut converged = false;
1219    let mut n_iter = 0usize;
1220
1221    for _iter in 0..max_iter {
1222        n_iter += 1;
1223        let sigma2_u_old = sigma2_u;
1224        let sigma2_e_old = sigma2_e;
1225
1226        let weights = shrinkage_weights(&ss, sigma2_u, sigma2_e);
1227
1228        if let Some(cov) = covariates.filter(|_| p > 0) {
1229            if let Some(g) = gls_update_gamma(cov, p, &ss, &weights, y, sigma2_e) {
1230                gamma = g;
1231            }
1232        }
1233
1234        let r = compute_ols_residuals(y, covariates, &gamma, p, n);
1235        (sigma2_u, sigma2_e) = reml_variance_update(&r, &ss, &weights, sigma2_u, p);
1236
1237        let delta = (sigma2_u - sigma2_u_old).abs() + (sigma2_e - sigma2_e_old).abs();
1238        if delta < tol * (sigma2_u_old + sigma2_e_old) {
1239            converged = true;
1240            break;
1241        }
1242    }
1243
1244    let final_residuals = compute_ols_residuals(y, covariates, &gamma, p, n);
1245    let u_hat = compute_blup(
1246        &final_residuals,
1247        subject_map,
1248        n_subjects,
1249        sigma2_u,
1250        sigma2_e,
1251    );
1252
1253    ScalarMixedResultWithMeta {
1254        result: ScalarMixedResult {
1255            gamma,
1256            u_hat,
1257            sigma2_u,
1258            sigma2_eps: sigma2_e,
1259        },
1260        n_iter,
1261        converged,
1262    }
1263}
1264
1265// ---------------------------------------------------------------------------
1266// multiFAMM — multivariate stacked extension
1267// ---------------------------------------------------------------------------
1268
1269/// Configuration for [`multi_famm`].
1270///
1271/// No `#[non_exhaustive]` — callers may use struct-literal construction.
1272#[derive(Debug, Clone, PartialEq)]
1273#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1274pub struct MultiFammConfig {
1275    /// Number of FPC components per response dimension (default: 3)
1276    pub ncomp: usize,
1277    /// Maximum REML EM iterations per component model (default: 50)
1278    pub max_iter: usize,
1279    /// Convergence tolerance (default: 1e-10)
1280    pub tol: f64,
1281}
1282
1283impl Default for MultiFammConfig {
1284    fn default() -> Self {
1285        Self {
1286            ncomp: 3,
1287            max_iter: 50,
1288            tol: 1e-10,
1289        }
1290    }
1291}
1292
1293/// Result of a multivariate FAMM fit.
1294///
1295/// # Divergence from R's `multiFAMM`
1296///
1297/// R's `multiFAMM` (Volkmann et al. 2021) uses joint multivariate FPCA so that
1298/// cross-dimension covariance kernels K_g(d,e)(t,t') are modelled. fdars instead
1299/// runs D independent univariate FPCAs (one per response dimension via
1300/// [`dense_flmm`]), capturing within-dimension structure but **not**
1301/// cross-dimension covariances. This is a documented capability divergence;
1302/// users requiring cross-dimension random effects should consider the R package.
1303#[derive(Debug, Clone, PartialEq)]
1304#[non_exhaustive]
1305#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1306pub struct MultiFammResult {
1307    /// Per-dimension FLMM results (length D)
1308    pub components: Vec<DenseFlmmResult>,
1309    /// Fitted values stacked row-wise across all dimensions: (n_total × D) × m
1310    pub stacked_fitted: FdMatrix,
1311    /// Residuals stacked row-wise across all dimensions: (n_total × D) × m
1312    pub stacked_residuals: FdMatrix,
1313    /// Number of response dimensions D
1314    pub n_dims: usize,
1315}
1316
1317/// Fit a multivariate functional additive mixed model.
1318///
1319/// Calls [`dense_flmm`] independently for each response dimension and stacks
1320/// the fitted curves and residuals row-wise.
1321///
1322/// All dimensions must share the same number of evaluation points (`ncols`).
1323///
1324/// # Divergence from R's `multiFAMM`
1325///
1326/// Cross-dimension covariance kernels are not modelled; see [`MultiFammResult`]
1327/// for details.
1328///
1329/// # Errors
1330///
1331/// Returns [`FdarError::InvalidDimension`] if:
1332/// - `data` is empty (zero dimensions),
1333/// - any dimension has zero rows or columns,
1334/// - dimensions differ in grid size (`ncols`), or
1335/// - `subject_ids` length mismatches `data[0].nrows()`.
1336///
1337/// Returns [`FdarError::InvalidParameter`] if `config.ncomp` is zero.
1338/// Propagates errors from [`dense_flmm`].
1339#[must_use = "expensive computation whose result should not be discarded"]
1340pub fn multi_famm(
1341    data: &[FdMatrix],
1342    subject_ids: &[usize],
1343    covariates: Option<&FdMatrix>,
1344    config: &MultiFammConfig,
1345) -> Result<MultiFammResult, FdarError> {
1346    let n_dims = data.len();
1347    if n_dims == 0 {
1348        return Err(FdarError::InvalidDimension {
1349            parameter: "data",
1350            expected: "at least one response dimension".to_string(),
1351            actual: "0 dimensions".to_string(),
1352        });
1353    }
1354
1355    let n_total = data[0].nrows();
1356    let m = data[0].ncols();
1357
1358    if n_total == 0 || m == 0 {
1359        return Err(FdarError::InvalidDimension {
1360            parameter: "data",
1361            expected: "non-empty matrix".to_string(),
1362            actual: format!("{n_total} x {m}"),
1363        });
1364    }
1365
1366    // Validate all dimensions share the same grid and row count
1367    for (d, dim) in data.iter().enumerate().skip(1) {
1368        if dim.ncols() != m {
1369            return Err(FdarError::InvalidDimension {
1370                parameter: "data",
1371                expected: format!("all dimensions share ncols = {m}"),
1372                actual: format!("dimension {d} has ncols = {}", dim.ncols()),
1373            });
1374        }
1375        if dim.nrows() != n_total {
1376            return Err(FdarError::InvalidDimension {
1377                parameter: "data",
1378                expected: format!("all dimensions share nrows = {n_total}"),
1379                actual: format!("dimension {d} has nrows = {}", dim.nrows()),
1380            });
1381        }
1382    }
1383
1384    // Build per-dimension DenseFlmmConfig from MultiFammConfig
1385    let dense_cfg = DenseFlmmConfig {
1386        ncomp: config.ncomp,
1387        max_iter: config.max_iter,
1388        tol: config.tol,
1389        random_slopes: false,
1390    };
1391
1392    // Fit each dimension independently
1393    let mut components: Vec<DenseFlmmResult> = Vec::with_capacity(n_dims);
1394    for dim_data in data.iter() {
1395        let result = dense_flmm(dim_data, subject_ids, covariates, &dense_cfg)?;
1396        components.push(result);
1397    }
1398
1399    // Stack fitted and residuals row-wise: (n_total * n_dims) × m
1400    let stacked_rows = n_total * n_dims;
1401    let mut stacked_fitted_data = vec![0.0; stacked_rows * m];
1402    let mut stacked_residuals_data = vec![0.0; stacked_rows * m];
1403
1404    for (d, comp) in components.iter().enumerate() {
1405        for i in 0..n_total {
1406            let row = d * n_total + i;
1407            for t in 0..m {
1408                // column-major: element (row, col) at index row + col * nrows
1409                stacked_fitted_data[row + t * stacked_rows] = comp.fitted[(i, t)];
1410                stacked_residuals_data[row + t * stacked_rows] = comp.residuals[(i, t)];
1411            }
1412        }
1413    }
1414
1415    let stacked_fitted = FdMatrix::from_column_major(stacked_fitted_data, stacked_rows, m)
1416        .map_err(|_| FdarError::ComputationFailed {
1417            operation: "multi_famm stacking",
1418            detail: "failed to build stacked_fitted matrix".to_string(),
1419        })?;
1420    let stacked_residuals = FdMatrix::from_column_major(stacked_residuals_data, stacked_rows, m)
1421        .map_err(|_| FdarError::ComputationFailed {
1422            operation: "multi_famm stacking",
1423            detail: "failed to build stacked_residuals matrix".to_string(),
1424        })?;
1425
1426    Ok(MultiFammResult {
1427        components,
1428        stacked_fitted,
1429        stacked_residuals,
1430        n_dims,
1431    })
1432}
1433
1434// ---------------------------------------------------------------------------
1435// fastFMM — massively-univariate per-gridpoint inference
1436// ---------------------------------------------------------------------------
1437
1438/// Configuration for [`fast_fmm`].
1439///
1440/// No `#[non_exhaustive]` — callers may use struct-literal construction.
1441#[derive(Debug, Clone, PartialEq)]
1442#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1443pub struct FastFmmConfig {
1444    /// Running-mean smoother window width along the grid axis (default: 3; 1 = no smoothing).
1445    ///
1446    /// # Divergence from R's `fastFMM`
1447    ///
1448    /// R's `fastFMM` uses mgcv thin-plate splines for post-smoothing. fdars uses
1449    /// a running-mean smoother configured by this window width. Savitzky-Golay
1450    /// smoothing (better peak preservation) is a planned future improvement.
1451    pub smooth_window: usize,
1452    /// Maximum iterations for each per-gridpoint scalar mixed model (default: 30)
1453    pub max_iter: usize,
1454    /// Convergence tolerance (default: 1e-8)
1455    pub tol: f64,
1456    /// Compute Wald t-statistics and pointwise p-values (default: true).
1457    ///
1458    /// When `false`, `t_stats` is zero-filled and `p_values` is one-filled.
1459    ///
1460    /// # Divergence from R's `fastFMM`
1461    ///
1462    /// R uses a bootstrap for non-Gaussian inference. fdars provides Wald-only
1463    /// (standard-normal approximation) inference.
1464    pub compute_inference: bool,
1465}
1466
1467impl Default for FastFmmConfig {
1468    fn default() -> Self {
1469        Self {
1470            smooth_window: 3,
1471            max_iter: 30,
1472            tol: 1e-8,
1473            compute_inference: true,
1474        }
1475    }
1476}
1477
1478/// Result of a fast massively-univariate functional mixed model fit.
1479///
1480/// # Divergence from R's `fastFMM`
1481///
1482/// R's `fastFMM` (Cui et al. 2022, JCGS 31(1):219–230) fits per-gridpoint
1483/// GLMMs via `lme4` and smooths via mgcv. fdars fits per-gridpoint scalar mixed
1484/// models via the existing REML-EM solver, smooths via running-mean, and
1485/// computes Wald-only inference — no bootstrap.
1486#[derive(Debug, Clone, PartialEq)]
1487#[non_exhaustive]
1488#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1489pub struct FastFmmResult {
1490    /// Smoothed fixed-effect functions: p × m matrix (one row per covariate)
1491    pub beta_matrix: FdMatrix,
1492    /// Wald t-statistics: p × m (zero-filled when `compute_inference = false`)
1493    pub t_stats: FdMatrix,
1494    /// Pointwise two-sided p-values: p × m (one-filled when `compute_inference = false`)
1495    pub p_values: FdMatrix,
1496    /// Per-gridpoint residual variance estimate (length m)
1497    pub sigma2_eps: Vec<f64>,
1498    /// Per-gridpoint random-intercept variance estimate (length m)
1499    pub sigma2_u: Vec<f64>,
1500    /// Number of grid points m
1501    pub n_grid: usize,
1502}
1503
1504/// Fit a fast massively-univariate functional mixed model.
1505///
1506/// Fits a scalar mixed model at each grid point independently, then
1507/// applies a running-mean smoother along the grid axis.
1508///
1509/// # Algorithm
1510///
1511/// 1. For each grid point t in 0..m: fit a scalar mixed model on `data.column(t)`
1512///    via REML-EM, producing raw (β̂(t), û_i(t), σ̂²_u(t), σ̂²_ε(t)).
1513/// 2. Smooth the raw p × m coefficient matrix with a running-mean window of
1514///    width `config.smooth_window` (window 1 = identity / no smoothing).
1515/// 3. When `config.compute_inference`: compute Wald t-statistics
1516///    `t_jt = β̂_j(t) / se_j(t)` using a standard-normal two-sided p-value.
1517///
1518/// # Errors
1519///
1520/// Returns [`FdarError::InvalidDimension`] if `data` is empty or `subject_ids`
1521/// length mismatches `data.nrows()`.
1522/// Returns [`FdarError::InvalidParameter`] if `config.smooth_window` is zero.
1523#[must_use = "expensive computation whose result should not be discarded"]
1524pub fn fast_fmm(
1525    data: &FdMatrix,
1526    subject_ids: &[usize],
1527    covariates: Option<&FdMatrix>,
1528    config: &FastFmmConfig,
1529) -> Result<FastFmmResult, FdarError> {
1530    let n_total = data.nrows();
1531    let m = data.ncols();
1532    if n_total == 0 || m == 0 {
1533        return Err(FdarError::InvalidDimension {
1534            parameter: "data",
1535            expected: "non-empty matrix".to_string(),
1536            actual: format!("{n_total} x {m}"),
1537        });
1538    }
1539    if subject_ids.len() != n_total {
1540        return Err(FdarError::InvalidDimension {
1541            parameter: "subject_ids",
1542            expected: format!("length {n_total}"),
1543            actual: format!("length {}", subject_ids.len()),
1544        });
1545    }
1546    if config.smooth_window == 0 {
1547        return Err(FdarError::InvalidParameter {
1548            parameter: "smooth_window",
1549            message: "must be >= 1 (use 1 for no smoothing)".to_string(),
1550        });
1551    }
1552    if config.max_iter == 0 {
1553        return Err(FdarError::InvalidParameter {
1554            parameter: "max_iter",
1555            message: "must be >= 1".to_string(),
1556        });
1557    }
1558
1559    let (subject_map, n_subjects) = build_subject_map(subject_ids);
1560    let p = covariates.map_or(0, super::matrix::FdMatrix::ncols);
1561
1562    // Per-gridpoint result container (immutable per-item for safe parallel collect)
1563    struct PointwiseResult {
1564        gamma: Vec<f64>, // length p (fixed effects at this grid point)
1565        sigma2_u: f64,   // random-intercept variance at this grid point
1566        sigma2_eps: f64, // residual variance at this grid point
1567    }
1568
1569    // Step 1: Fit per-gridpoint scalar mixed models
1570    // Use column-major zero-copy access: data.column(t) is a contiguous &[f64]
1571    let per_point: Vec<PointwiseResult> = iter_maybe_parallel!(0..m)
1572        .map(|t| {
1573            let y_t: Vec<f64> = data.column(t).to_vec();
1574            let r = fit_scalar_mixed_model_tracked(
1575                &y_t,
1576                &subject_map,
1577                n_subjects,
1578                covariates,
1579                p,
1580                config.max_iter,
1581                config.tol,
1582            );
1583            PointwiseResult {
1584                gamma: r.result.gamma,
1585                sigma2_u: r.result.sigma2_u,
1586                sigma2_eps: r.result.sigma2_eps,
1587            }
1588        })
1589        .collect();
1590
1591    // Unpack into raw p × m beta matrix and per-gridpoint variances
1592    let mut raw_beta_data = vec![0.0; p * m]; // row-by-row in column-major: row=j, col=t
1593    let mut sigma2_eps_vec = vec![0.0; m];
1594    let mut sigma2_u_vec = vec![0.0; m];
1595
1596    for (t, pt) in per_point.iter().enumerate() {
1597        // Fill column-major beta: element (j, t) at index j + t * p
1598        for j in 0..p {
1599            raw_beta_data[j + t * p] = pt.gamma.get(j).copied().unwrap_or(0.0);
1600        }
1601        sigma2_eps_vec[t] = pt.sigma2_eps;
1602        sigma2_u_vec[t] = pt.sigma2_u;
1603    }
1604
1605    // Step 2: Running-mean smoothing along the grid axis (per covariate row)
1606    // Force smooth_window to an odd value so the half-width formula `half = w / 2`
1607    // produces a symmetric window of exactly `w` elements (for even w the range
1608    // [t-half, t+half+1) would be w+1 elements wide — one too many).
1609    let w = if config.smooth_window % 2 == 0 {
1610        config.smooth_window + 1
1611    } else {
1612        config.smooth_window
1613    };
1614    let mut smoothed_beta_data = raw_beta_data.clone();
1615    if w > 1 && m > 1 {
1616        let half = w / 2;
1617        for j in 0..p {
1618            for t in 0..m {
1619                let lo = t.saturating_sub(half);
1620                let hi = (t + half + 1).min(m);
1621                let count = (hi - lo) as f64;
1622                let sum: f64 = (lo..hi).map(|tt| raw_beta_data[j + tt * p]).sum();
1623                smoothed_beta_data[j + t * p] = sum / count;
1624            }
1625        }
1626    }
1627
1628    // Build the smoothed beta FdMatrix (p × m, column-major)
1629    let beta_matrix = if p > 0 {
1630        FdMatrix::from_column_major(smoothed_beta_data, p, m).map_err(|_| {
1631            FdarError::ComputationFailed {
1632                operation: "fast_fmm",
1633                detail: "failed to build beta_matrix".to_string(),
1634            }
1635        })?
1636    } else {
1637        FdMatrix::zeros(0, m)
1638    };
1639
1640    // Step 3: Wald inference
1641    let (t_stats, p_values) = if config.compute_inference && p > 0 {
1642        // Compute X'X for standard errors using the first non-zero observation
1643        // SE²_j(t) = sigma2_eps(t) * (X'X)^{-1}_{jj}
1644        // We accumulate X'X once (same design for all t) then invert
1645        let xtx_inv_diag = compute_xtx_inv_diag(covariates, p, n_total);
1646
1647        let mut t_data = vec![0.0f64; p * m];
1648        let mut pv_data = vec![1.0f64; p * m];
1649
1650        for j in 0..p {
1651            for t in 0..m {
1652                let beta_jt = beta_matrix[(j, t)];
1653                let se_sq = sigma2_eps_vec[t] * xtx_inv_diag.get(j).copied().unwrap_or(1.0);
1654                let se = se_sq.sqrt().max(1e-15);
1655                let t_stat = beta_jt / se;
1656                let pval = 2.0 * normal_sf(t_stat.abs());
1657                t_data[j + t * p] = t_stat;
1658                pv_data[j + t * p] = pval.clamp(0.0, 1.0);
1659            }
1660        }
1661
1662        let ts = FdMatrix::from_column_major(t_data, p, m).map_err(|_| {
1663            FdarError::ComputationFailed {
1664                operation: "fast_fmm",
1665                detail: "failed to build t_stats".to_string(),
1666            }
1667        })?;
1668        let pv = FdMatrix::from_column_major(pv_data, p, m).map_err(|_| {
1669            FdarError::ComputationFailed {
1670                operation: "fast_fmm",
1671                detail: "failed to build p_values".to_string(),
1672            }
1673        })?;
1674        (ts, pv)
1675    } else {
1676        // No inference or no covariates: zeros / ones
1677        (FdMatrix::zeros(p, m), ones_fdmatrix(p, m))
1678    };
1679
1680    Ok(FastFmmResult {
1681        beta_matrix,
1682        t_stats,
1683        p_values,
1684        sigma2_eps: sigma2_eps_vec,
1685        sigma2_u: sigma2_u_vec,
1686        n_grid: m,
1687    })
1688}
1689
1690/// Compute diagonal of (X'X)^{-1} for Wald standard errors.
1691fn compute_xtx_inv_diag(covariates: Option<&FdMatrix>, p: usize, n: usize) -> Vec<f64> {
1692    let Some(cov) = covariates else {
1693        return vec![1.0; p];
1694    };
1695    let mut xtx = vec![0.0; p * p];
1696    for i in 0..n {
1697        for r in 0..p {
1698            for s in r..p {
1699                let val = cov[(i, r)] * cov[(i, s)];
1700                xtx[r * p + s] += val;
1701                if r != s {
1702                    xtx[s * p + r] += val;
1703                }
1704            }
1705        }
1706    }
1707    for j in 0..p {
1708        xtx[j * p + j] += 1e-8;
1709    }
1710    // Invert via Cholesky; fall back to reciprocal diagonal if singular
1711    if let Some(inv) = cholesky_invert(&xtx, p) {
1712        (0..p).map(|j| inv[j * p + j].max(1e-15)).collect()
1713    } else {
1714        // Fallback: diagonal only
1715        (0..p)
1716            .map(|j| {
1717                let d = xtx[j * p + j];
1718                if d > 1e-15 {
1719                    1.0 / d
1720                } else {
1721                    1.0
1722                }
1723            })
1724            .collect()
1725    }
1726}
1727
1728/// Invert a p×p symmetric positive definite matrix via Cholesky.
1729fn cholesky_invert(a: &[f64], p: usize) -> Option<Vec<f64>> {
1730    let l = linalg_cholesky_factor(a, p).ok()?;
1731    // Solve A * X = I column by column
1732    let mut inv = vec![0.0; p * p];
1733    let mut e = vec![0.0; p];
1734    for j in 0..p {
1735        e.fill(0.0);
1736        e[j] = 1.0;
1737        let col = linalg_cholesky_forward_back(&l, &e, p);
1738        for i in 0..p {
1739            inv[i * p + j] = col[i];
1740        }
1741    }
1742    Some(inv)
1743}
1744
1745/// Standard normal survival function: P(Z > x) using erf approximation.
1746fn normal_sf(x: f64) -> f64 {
1747    // 0.5 * erfc(x / sqrt(2))
1748    0.5 * erfc(x / core::f64::consts::SQRT_2)
1749}
1750
1751/// Complementary error function approximation (Abramowitz & Stegun 7.1.26).
1752fn erfc(x: f64) -> f64 {
1753    // Handle negative x via symmetry: erfc(-x) = 2 - erfc(x)
1754    if x < 0.0 {
1755        return 2.0 - erfc(-x);
1756    }
1757    // Rational approximation valid for x >= 0, max |error| < 1.5e-7
1758    let t = 1.0 / (1.0 + 0.3275911 * x);
1759    let poly = t
1760        * (0.254_829_592
1761            + t * (-0.284_496_736
1762                + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
1763    poly * (-x * x).exp()
1764}
1765
1766/// Create an FdMatrix filled with ones (p × m).
1767fn ones_fdmatrix(p: usize, m: usize) -> FdMatrix {
1768    if p == 0 || m == 0 {
1769        return FdMatrix::zeros(p, m);
1770    }
1771    let data = vec![1.0f64; p * m];
1772    FdMatrix::from_column_major(data, p, m).unwrap_or_else(|_| FdMatrix::zeros(p, m))
1773}
1774
1775// ---------------------------------------------------------------------------
1776// Tests
1777// ---------------------------------------------------------------------------
1778
1779#[cfg(test)]
1780mod tests {
1781    use super::*;
1782    use crate::test_helpers::uniform_grid;
1783    use std::f64::consts::PI;
1784
1785    /// Generate repeated measurements: n_subjects × n_visits curves.
1786    /// Subject-level covariate z affects the curve amplitude.
1787    fn generate_fmm_data(
1788        n_subjects: usize,
1789        n_visits: usize,
1790        m: usize,
1791    ) -> (FdMatrix, Vec<usize>, FdMatrix, Vec<f64>) {
1792        let t = uniform_grid(m);
1793        let n_total = n_subjects * n_visits;
1794        let mut col_major = vec![0.0; n_total * m];
1795        let mut subject_ids = vec![0usize; n_total];
1796        let mut cov_data = vec![0.0; n_total];
1797
1798        for s in 0..n_subjects {
1799            let z = s as f64 / n_subjects as f64; // covariate in [0, 1)
1800            let subject_effect = 0.5 * (s as f64 - n_subjects as f64 / 2.0); // random-like effect
1801
1802            for v in 0..n_visits {
1803                let obs = s * n_visits + v;
1804                subject_ids[obs] = s;
1805                cov_data[obs] = z;
1806                let noise_scale = 0.05;
1807
1808                for (j, &tj) in t.iter().enumerate() {
1809                    // Y_sv(t) = sin(2πt) + z * t + subject_effect * cos(2πt) + noise
1810                    let mu = (2.0 * PI * tj).sin();
1811                    let fixed = z * tj * 3.0;
1812                    let random = subject_effect * (2.0 * PI * tj).cos() * 0.3;
1813                    let noise = noise_scale * ((obs * 7 + j * 3) % 100) as f64 / 100.0;
1814                    col_major[obs + j * n_total] = mu + fixed + random + noise;
1815                }
1816            }
1817        }
1818
1819        let data = FdMatrix::from_column_major(col_major, n_total, m).unwrap();
1820        let covariates = FdMatrix::from_column_major(cov_data, n_total, 1).unwrap();
1821        (data, subject_ids, covariates, t)
1822    }
1823
1824    #[test]
1825    fn test_fmm_basic() {
1826        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 50);
1827        let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1828
1829        assert_eq!(result.mean_function.len(), 50);
1830        assert_eq!(result.beta_functions.nrows(), 1); // 1 covariate
1831        assert_eq!(result.beta_functions.ncols(), 50);
1832        assert_eq!(result.random_effects.nrows(), 10);
1833        assert_eq!(result.fitted.nrows(), 30);
1834        assert_eq!(result.residuals.nrows(), 30);
1835        assert_eq!(result.n_subjects, 10);
1836    }
1837
1838    #[test]
1839    fn test_fmm_fitted_plus_residuals_equals_data() {
1840        let (data, subject_ids, covariates, _t) = generate_fmm_data(8, 3, 40);
1841        let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1842
1843        let n = data.nrows();
1844        let m = data.ncols();
1845        for i in 0..n {
1846            for t in 0..m {
1847                let reconstructed = result.fitted[(i, t)] + result.residuals[(i, t)];
1848                assert!(
1849                    (reconstructed - data[(i, t)]).abs() < 1e-8,
1850                    "Fitted + residual should equal data at ({}, {}): {} vs {}",
1851                    i,
1852                    t,
1853                    reconstructed,
1854                    data[(i, t)]
1855                );
1856            }
1857        }
1858    }
1859
1860    #[test]
1861    fn test_fmm_random_variance_positive() {
1862        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 50);
1863        let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1864
1865        for &v in &result.random_variance {
1866            assert!(v >= 0.0, "Random variance should be non-negative");
1867        }
1868    }
1869
1870    #[test]
1871    fn test_fmm_no_covariates() {
1872        let (data, subject_ids, _cov, _t) = generate_fmm_data(8, 3, 40);
1873        let result = fmm(&data, &subject_ids, None, 3).unwrap();
1874
1875        assert_eq!(result.beta_functions.nrows(), 0);
1876        assert_eq!(result.n_subjects, 8);
1877        assert_eq!(result.fitted.nrows(), 24);
1878    }
1879
1880    #[test]
1881    fn test_fmm_predict() {
1882        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 50);
1883        let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1884
1885        // Predict for new subjects with covariate = 0.5
1886        let new_cov = FdMatrix::from_column_major(vec![0.5], 1, 1).unwrap();
1887        let predicted = fmm_predict(&result, Some(&new_cov));
1888
1889        assert_eq!(predicted.nrows(), 1);
1890        assert_eq!(predicted.ncols(), 50);
1891
1892        // Predicted curve should be reasonable (not NaN or extreme)
1893        for t in 0..50 {
1894            assert!(predicted[(0, t)].is_finite());
1895            assert!(
1896                predicted[(0, t)].abs() < 20.0,
1897                "Predicted value too extreme at t={}: {}",
1898                t,
1899                predicted[(0, t)]
1900            );
1901        }
1902    }
1903
1904    #[test]
1905    fn test_fmm_test_fixed_detects_effect() {
1906        let (data, subject_ids, covariates, _t) = generate_fmm_data(15, 3, 40);
1907
1908        let result = fmm_test_fixed(&data, &subject_ids, &covariates, 3, 99, 42).unwrap();
1909
1910        assert_eq!(result.f_statistics.len(), 1);
1911        assert_eq!(result.p_values.len(), 1);
1912        assert!(
1913            result.p_values[0] < 0.1,
1914            "Should detect covariate effect, got p={}",
1915            result.p_values[0]
1916        );
1917    }
1918
1919    #[test]
1920    fn test_fmm_test_fixed_no_effect() {
1921        let n_subjects = 10;
1922        let n_visits = 3;
1923        let m = 40;
1924        let t = uniform_grid(m);
1925        let n_total = n_subjects * n_visits;
1926
1927        // No covariate effect: Y = sin(2πt) + noise
1928        let mut col_major = vec![0.0; n_total * m];
1929        let mut subject_ids = vec![0usize; n_total];
1930        let mut cov_data = vec![0.0; n_total];
1931
1932        for s in 0..n_subjects {
1933            for v in 0..n_visits {
1934                let obs = s * n_visits + v;
1935                subject_ids[obs] = s;
1936                cov_data[obs] = s as f64 / n_subjects as f64;
1937                for (j, &tj) in t.iter().enumerate() {
1938                    col_major[obs + j * n_total] =
1939                        (2.0 * PI * tj).sin() + 0.1 * ((obs * 7 + j * 3) % 100) as f64 / 100.0;
1940                }
1941            }
1942        }
1943
1944        let data = FdMatrix::from_column_major(col_major, n_total, m).unwrap();
1945        let covariates = FdMatrix::from_column_major(cov_data, n_total, 1).unwrap();
1946
1947        let result = fmm_test_fixed(&data, &subject_ids, &covariates, 3, 99, 42).unwrap();
1948        assert!(
1949            result.p_values[0] > 0.05,
1950            "Should not detect effect, got p={}",
1951            result.p_values[0]
1952        );
1953    }
1954
1955    #[test]
1956    fn test_fmm_invalid_input() {
1957        let data = FdMatrix::zeros(0, 0);
1958        assert!(fmm(&data, &[], None, 1).is_err());
1959
1960        let data = FdMatrix::zeros(10, 50);
1961        let ids = vec![0; 5]; // wrong length
1962        assert!(fmm(&data, &ids, None, 1).is_err());
1963    }
1964
1965    #[test]
1966    fn test_fmm_single_visit_per_subject() {
1967        let n = 10;
1968        let m = 40;
1969        let t = uniform_grid(m);
1970        let mut col_major = vec![0.0; n * m];
1971        let subject_ids: Vec<usize> = (0..n).collect();
1972
1973        for i in 0..n {
1974            for (j, &tj) in t.iter().enumerate() {
1975                col_major[i + j * n] = (2.0 * PI * tj).sin();
1976            }
1977        }
1978        let data = FdMatrix::from_column_major(col_major, n, m).unwrap();
1979
1980        // Should still work with 1 visit per subject
1981        let result = fmm(&data, &subject_ids, None, 2).unwrap();
1982        assert_eq!(result.n_subjects, n);
1983        assert_eq!(result.fitted.nrows(), n);
1984    }
1985
1986    #[test]
1987    fn test_build_subject_map() {
1988        let (map, n) = build_subject_map(&[5, 5, 10, 10, 20]);
1989        assert_eq!(n, 3);
1990        assert_eq!(map, vec![0, 0, 1, 1, 2]);
1991    }
1992
1993    #[test]
1994    fn test_variance_components_positive() {
1995        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 50);
1996        let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1997
1998        assert!(result.sigma2_eps >= 0.0);
1999        for &s in &result.sigma2_u {
2000            assert!(s >= 0.0);
2001        }
2002    }
2003
2004    // -------------------------------------------------------------------
2005    // Additional tests
2006    // -------------------------------------------------------------------
2007
2008    #[test]
2009    fn test_fmm_ncomp_zero_returns_error() {
2010        let (data, subject_ids, _cov, _t) = generate_fmm_data(5, 2, 20);
2011        let err = fmm(&data, &subject_ids, None, 0).unwrap_err();
2012        match err {
2013            FdarError::InvalidParameter { parameter, .. } => {
2014                assert_eq!(parameter, "ncomp");
2015            }
2016            other => panic!("Expected InvalidParameter, got {:?}", other),
2017        }
2018    }
2019
2020    #[test]
2021    fn test_fmm_single_component() {
2022        // Fit with only 1 FPC component
2023        let (data, subject_ids, covariates, _t) = generate_fmm_data(8, 3, 30);
2024        let result = fmm(&data, &subject_ids, Some(&covariates), 1).unwrap();
2025
2026        assert_eq!(result.ncomp, 1);
2027        assert_eq!(result.sigma2_u.len(), 1);
2028        assert_eq!(result.eigenvalues.len(), 1);
2029        assert_eq!(result.mean_function.len(), 30);
2030        // Fitted + residuals = data
2031        for i in 0..data.nrows() {
2032            for t in 0..data.ncols() {
2033                let diff = (result.fitted[(i, t)] + result.residuals[(i, t)] - data[(i, t)]).abs();
2034                assert!(diff < 1e-8);
2035            }
2036        }
2037    }
2038
2039    #[test]
2040    fn test_fmm_two_subjects() {
2041        // Minimal number of subjects (2) with multiple visits
2042        let n_subjects = 2;
2043        let n_visits = 5;
2044        let m = 20;
2045        let t = uniform_grid(m);
2046        let n_total = n_subjects * n_visits;
2047        let mut col_major = vec![0.0; n_total * m];
2048        let mut subject_ids = vec![0usize; n_total];
2049
2050        for s in 0..n_subjects {
2051            for v in 0..n_visits {
2052                let obs = s * n_visits + v;
2053                subject_ids[obs] = s;
2054                for (j, &tj) in t.iter().enumerate() {
2055                    col_major[obs + j * n_total] =
2056                        (2.0 * PI * tj).sin() + (s as f64) * 0.5 + 0.01 * v as f64;
2057                }
2058            }
2059        }
2060        let data = FdMatrix::from_column_major(col_major, n_total, m).unwrap();
2061        let result = fmm(&data, &subject_ids, None, 2).unwrap();
2062
2063        assert_eq!(result.n_subjects, 2);
2064        assert_eq!(result.random_effects.nrows(), 2);
2065        assert_eq!(result.fitted.nrows(), n_total);
2066    }
2067
2068    #[test]
2069    fn test_fmm_predict_no_covariates() {
2070        let (data, subject_ids, _cov, _t) = generate_fmm_data(6, 3, 30);
2071        let result = fmm(&data, &subject_ids, None, 2).unwrap();
2072
2073        // Predict without covariates — should return mean function
2074        let predicted = fmm_predict(&result, None);
2075        assert_eq!(predicted.nrows(), 1);
2076        assert_eq!(predicted.ncols(), 30);
2077        for t in 0..30 {
2078            let diff = (predicted[(0, t)] - result.mean_function[t]).abs();
2079            assert!(
2080                diff < 1e-12,
2081                "Without covariates, prediction should equal mean"
2082            );
2083        }
2084    }
2085
2086    #[test]
2087    fn test_fmm_predict_multiple_new_subjects() {
2088        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 40);
2089        let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
2090
2091        // Predict for 3 new subjects with different covariate values
2092        let new_cov = FdMatrix::from_column_major(vec![0.1, 0.5, 0.9], 3, 1).unwrap();
2093        let predicted = fmm_predict(&result, Some(&new_cov));
2094
2095        assert_eq!(predicted.nrows(), 3);
2096        assert_eq!(predicted.ncols(), 40);
2097
2098        // All predictions should be finite
2099        for i in 0..3 {
2100            for t in 0..40 {
2101                assert!(predicted[(i, t)].is_finite());
2102            }
2103        }
2104
2105        // Predictions for different covariates should differ
2106        let diff_01: f64 = (0..40)
2107            .map(|t| (predicted[(0, t)] - predicted[(1, t)]).powi(2))
2108            .sum();
2109        assert!(
2110            diff_01 > 1e-10,
2111            "Different covariates should yield different predictions"
2112        );
2113    }
2114
2115    #[test]
2116    fn test_fmm_eigenvalues_decreasing() {
2117        let (data, subject_ids, _cov, _t) = generate_fmm_data(10, 3, 50);
2118        let result = fmm(&data, &subject_ids, None, 5).unwrap();
2119
2120        // Eigenvalues should be in decreasing order (from FPCA)
2121        for i in 1..result.eigenvalues.len() {
2122            assert!(
2123                result.eigenvalues[i] <= result.eigenvalues[i - 1] + 1e-10,
2124                "Eigenvalues should be non-increasing: {} > {}",
2125                result.eigenvalues[i],
2126                result.eigenvalues[i - 1]
2127            );
2128        }
2129    }
2130
2131    #[test]
2132    fn test_fmm_random_effects_sum_near_zero() {
2133        // Random effects should approximately sum to zero across subjects
2134        let (data, subject_ids, covariates, _t) = generate_fmm_data(20, 3, 40);
2135        let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
2136
2137        let m = result.mean_function.len();
2138        for t in 0..m {
2139            let sum: f64 = (0..result.n_subjects)
2140                .map(|s| result.random_effects[(s, t)])
2141                .sum();
2142            let mean_abs: f64 = (0..result.n_subjects)
2143                .map(|s| result.random_effects[(s, t)].abs())
2144                .sum::<f64>()
2145                / result.n_subjects as f64;
2146            // Relative to the scale of random effects, the sum should be small
2147            if mean_abs > 1e-10 {
2148                assert!(
2149                    (sum / result.n_subjects as f64).abs() < mean_abs * 2.0,
2150                    "Random effects should roughly center around zero at t={}: sum={}, mean_abs={}",
2151                    t,
2152                    sum,
2153                    mean_abs
2154                );
2155            }
2156        }
2157    }
2158
2159    #[test]
2160    fn test_fmm_subject_ids_mismatch_error() {
2161        let data = FdMatrix::zeros(10, 20);
2162        let ids = vec![0; 7]; // wrong length
2163        let err = fmm(&data, &ids, None, 1).unwrap_err();
2164        match err {
2165            FdarError::InvalidDimension { parameter, .. } => {
2166                assert_eq!(parameter, "subject_ids");
2167            }
2168            other => panic!("Expected InvalidDimension, got {:?}", other),
2169        }
2170    }
2171
2172    #[test]
2173    fn test_fmm_test_fixed_empty_data_error() {
2174        let data = FdMatrix::zeros(0, 0);
2175        let covariates = FdMatrix::zeros(0, 1);
2176        let err = fmm_test_fixed(&data, &[], &covariates, 1, 10, 42).unwrap_err();
2177        match err {
2178            FdarError::InvalidDimension { parameter, .. } => {
2179                assert_eq!(parameter, "data");
2180            }
2181            other => panic!("Expected InvalidDimension for data, got {:?}", other),
2182        }
2183    }
2184
2185    #[test]
2186    fn test_fmm_test_fixed_zero_covariates_error() {
2187        let data = FdMatrix::zeros(10, 20);
2188        let ids = vec![0; 10];
2189        let covariates = FdMatrix::zeros(10, 0);
2190        let err = fmm_test_fixed(&data, &ids, &covariates, 1, 10, 42).unwrap_err();
2191        match err {
2192            FdarError::InvalidDimension { parameter, .. } => {
2193                assert_eq!(parameter, "covariates");
2194            }
2195            other => panic!("Expected InvalidDimension for covariates, got {:?}", other),
2196        }
2197    }
2198
2199    #[test]
2200    fn test_build_subject_map_single_subject() {
2201        let (map, n) = build_subject_map(&[42, 42, 42]);
2202        assert_eq!(n, 1);
2203        assert_eq!(map, vec![0, 0, 0]);
2204    }
2205
2206    #[test]
2207    fn test_build_subject_map_non_contiguous_ids() {
2208        let (map, n) = build_subject_map(&[100, 200, 100, 300, 200]);
2209        assert_eq!(n, 3);
2210        // sorted unique: [100, 200, 300] -> indices [0, 1, 2]
2211        assert_eq!(map, vec![0, 1, 0, 2, 1]);
2212    }
2213
2214    #[test]
2215    fn test_fmm_many_components_clamped() {
2216        // Request more components than available; FPCA should clamp
2217        let (data, subject_ids, _cov, _t) = generate_fmm_data(5, 3, 20);
2218        let n_total = data.nrows();
2219        // Request 100 components — should be clamped to min(n_total, m) - 1
2220        let result = fmm(&data, &subject_ids, None, 100).unwrap();
2221        assert!(
2222            result.ncomp <= n_total.min(20),
2223            "ncomp should be clamped: got {}",
2224            result.ncomp
2225        );
2226        assert!(result.ncomp >= 1);
2227    }
2228
2229    #[test]
2230    fn test_fmm_residuals_small_with_enough_components() {
2231        // With enough components, residuals should be small relative to data
2232        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 30);
2233        let result = fmm(&data, &subject_ids, Some(&covariates), 5).unwrap();
2234
2235        let n = data.nrows();
2236        let m = data.ncols();
2237        let mut data_ss = 0.0_f64;
2238        let mut resid_ss = 0.0_f64;
2239        for i in 0..n {
2240            for t in 0..m {
2241                data_ss += data[(i, t)].powi(2);
2242                resid_ss += result.residuals[(i, t)].powi(2);
2243            }
2244        }
2245
2246        // R-squared should be reasonably high for structured data
2247        let r_squared = 1.0 - resid_ss / data_ss;
2248        assert!(
2249            r_squared > 0.5,
2250            "R-squared should be high with enough components: {}",
2251            r_squared
2252        );
2253    }
2254
2255    // -----------------------------------------------------------------------
2256    // dense_flmm tests
2257    // -----------------------------------------------------------------------
2258
2259    #[test]
2260    fn test_dense_flmm_basic() {
2261        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 30);
2262        let cfg = DenseFlmmConfig::default();
2263        let result = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2264        assert_eq!(result.ncomp, cfg.ncomp);
2265        assert_eq!(result.n_subjects, 10);
2266        assert_eq!(result.mean_function.len(), 30);
2267        assert_eq!(result.beta_functions.ncols(), 30);
2268        assert_eq!(result.random_variance.len(), 30);
2269        assert_eq!(result.sigma2_u.len(), cfg.ncomp);
2270        // Random-slope variance is always present, zero-filled this release.
2271        assert_eq!(result.sigma2_slope.len(), cfg.ncomp);
2272        assert!(result.sigma2_slope.iter().all(|&v| v == 0.0));
2273    }
2274
2275    #[test]
2276    fn test_dense_flmm_fitted_plus_residuals_equals_data() {
2277        let (data, subject_ids, covariates, _t) = generate_fmm_data(8, 3, 24);
2278        let cfg = DenseFlmmConfig::default();
2279        let result = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2280        let n = data.nrows();
2281        let m = data.ncols();
2282        for i in 0..n {
2283            for j in 0..m {
2284                let recon = result.fitted[(i, j)] + result.residuals[(i, j)];
2285                assert!(
2286                    (recon - data[(i, j)]).abs() < 1e-6,
2287                    "fitted+residuals must equal data at ({i},{j})"
2288                );
2289            }
2290        }
2291    }
2292
2293    #[test]
2294    fn test_dense_flmm_recovers_signal_and_positive_variance() {
2295        let (data, subject_ids, covariates, _t) = generate_fmm_data(12, 4, 30);
2296        let cfg = DenseFlmmConfig {
2297            ncomp: 4,
2298            ..Default::default()
2299        };
2300        let result = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2301        // Residuals shrink relative to a mean-only baseline (fit tracks the truth).
2302        let n = data.nrows();
2303        let m = data.ncols();
2304        let mut col_means = vec![0.0; m];
2305        for j in 0..m {
2306            for i in 0..n {
2307                col_means[j] += data[(i, j)];
2308            }
2309            col_means[j] /= n as f64;
2310        }
2311        let (mut base_ss, mut resid_ss) = (0.0_f64, 0.0_f64);
2312        for i in 0..n {
2313            for j in 0..m {
2314                base_ss += (data[(i, j)] - col_means[j]).powi(2);
2315                resid_ss += result.residuals[(i, j)].powi(2);
2316            }
2317        }
2318        assert!(
2319            resid_ss < 0.5 * base_ss,
2320            "mixed model should explain most variance: resid={resid_ss}, base={base_ss}"
2321        );
2322        // At least one FPC component has positive random-intercept variance.
2323        assert!(result.sigma2_u.iter().any(|&v| v > 0.0));
2324    }
2325
2326    #[test]
2327    fn test_dense_flmm_invalid_inputs() {
2328        let cfg = DenseFlmmConfig::default();
2329        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
2330        assert!(dense_flmm(&empty, &[], None, &cfg).is_err());
2331
2332        let (data, subject_ids, _cov, _t) = generate_fmm_data(4, 2, 10);
2333        // Mismatched subject_ids length.
2334        let bad_ids = vec![0usize; subject_ids.len() + 1];
2335        assert!(dense_flmm(&data, &bad_ids, None, &cfg).is_err());
2336
2337        // ncomp == 0.
2338        let bad_cfg = DenseFlmmConfig {
2339            ncomp: 0,
2340            ..Default::default()
2341        };
2342        assert!(dense_flmm(&data, &subject_ids, None, &bad_cfg).is_err());
2343    }
2344
2345    // -----------------------------------------------------------------------
2346    // multi_famm tests
2347    // -----------------------------------------------------------------------
2348
2349    #[test]
2350    fn test_multi_famm_basic() {
2351        let (d0, subject_ids, cov, _t) = generate_fmm_data(10, 3, 20);
2352        let (d1, _s1, _c1, _t1) = generate_fmm_data(10, 3, 20);
2353        let cfg = MultiFammConfig {
2354            ncomp: 3,
2355            max_iter: 50,
2356            tol: 1e-10,
2357        };
2358        let result = multi_famm(&[d0, d1], &subject_ids, Some(&cov), &cfg).unwrap();
2359        assert_eq!(result.n_dims, 2);
2360        assert_eq!(result.components.len(), 2);
2361        // Stacked matrices carry D * n_total rows.
2362        assert_eq!(result.stacked_fitted.nrows(), 2 * subject_ids.len());
2363        assert_eq!(result.stacked_residuals.nrows(), 2 * subject_ids.len());
2364    }
2365
2366    #[test]
2367    fn test_multi_famm_invalid_inputs() {
2368        let cfg = MultiFammConfig {
2369            ncomp: 3,
2370            max_iter: 50,
2371            tol: 1e-10,
2372        };
2373        // Empty dimension list.
2374        assert!(multi_famm(&[], &[], None, &cfg).is_err());
2375
2376        // Grid-size mismatch between dimensions.
2377        let (d0, subject_ids, cov, _t) = generate_fmm_data(6, 2, 20);
2378        let (d1, _s1, _c1, _t1) = generate_fmm_data(6, 2, 25);
2379        assert!(multi_famm(&[d0, d1], &subject_ids, Some(&cov), &cfg).is_err());
2380    }
2381
2382    // -----------------------------------------------------------------------
2383    // fast_fmm tests
2384    // -----------------------------------------------------------------------
2385
2386    #[test]
2387    fn test_fast_fmm_basic() {
2388        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 20);
2389        let cfg = FastFmmConfig::default();
2390        let result = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2391        assert_eq!(result.n_grid, 20);
2392        assert_eq!(result.beta_matrix.ncols(), 20);
2393        assert_eq!(result.p_values.ncols(), 20);
2394        assert_eq!(result.sigma2_eps.len(), 20);
2395        // p-values must be valid probabilities, t-stats finite.
2396        for i in 0..result.p_values.nrows() {
2397            for j in 0..result.p_values.ncols() {
2398                let p = result.p_values[(i, j)];
2399                assert!((0.0..=1.0).contains(&p), "p-value out of range: {p}");
2400                assert!(result.t_stats[(i, j)].is_finite());
2401            }
2402        }
2403    }
2404
2405    #[test]
2406    fn test_fast_fmm_invalid_inputs() {
2407        let (data, subject_ids, _cov, _t) = generate_fmm_data(4, 2, 10);
2408        // smooth_window == 0.
2409        let bad_cfg = FastFmmConfig {
2410            smooth_window: 0,
2411            ..Default::default()
2412        };
2413        assert!(fast_fmm(&data, &subject_ids, None, &bad_cfg).is_err());
2414
2415        // Mismatched subject_ids length.
2416        let cfg = FastFmmConfig::default();
2417        let bad_ids = vec![0usize; subject_ids.len() + 1];
2418        assert!(fast_fmm(&data, &bad_ids, None, &cfg).is_err());
2419    }
2420
2421    // -----------------------------------------------------------------------
2422    // REG-05-G: dense_flmm converged field is exercised (WR-04)
2423    // -----------------------------------------------------------------------
2424
2425    #[test]
2426    fn test_dense_flmm_converged() {
2427        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 20);
2428        // With plenty of iterations, well-conditioned data should converge.
2429        let cfg = DenseFlmmConfig {
2430            max_iter: 100,
2431            ..Default::default()
2432        };
2433        let result = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2434        assert!(result.converged, "should converge with 100 iterations");
2435
2436        // With max_iter=1 and a very tight tol, convergence should fail to be
2437        // reported (n_iter reported is exactly 1).
2438        let tight_cfg = DenseFlmmConfig {
2439            max_iter: 1,
2440            tol: 1e-30,
2441            ..Default::default()
2442        };
2443        let result2 = dense_flmm(&data, &subject_ids, Some(&covariates), &tight_cfg).unwrap();
2444        assert_eq!(result2.n_iter, 1, "expected exactly 1 iteration");
2445        // With 1 iteration and a near-impossible tolerance, converged is likely false.
2446        // We do not assert it is false (could converge in 1 step on degenerate data),
2447        // but we do verify n_iter is tracked correctly.
2448    }
2449
2450    // -----------------------------------------------------------------------
2451    // REG-05-K: fast_fmm detects a real fixed effect (WR-04)
2452    // -----------------------------------------------------------------------
2453
2454    #[test]
2455    fn test_fast_fmm_detects_effect() {
2456        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 20);
2457        let cfg = FastFmmConfig {
2458            compute_inference: true,
2459            ..Default::default()
2460        };
2461        let result = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2462        // beta_matrix row 0 (the covariate effect) should be non-zero since the
2463        // data-generating process includes a fixed covariate term z * t * 3.
2464        let norm_sq: f64 = (0..result.beta_matrix.ncols())
2465            .map(|t| result.beta_matrix[(0, t)].powi(2))
2466            .sum();
2467        assert!(
2468            norm_sq > 0.0,
2469            "beta_matrix row 0 should be non-zero for data with a real covariate effect"
2470        );
2471        // At least some grid points should show a meaningful t-statistic.
2472        let max_abs_t: f64 = (0..result.t_stats.ncols())
2473            .map(|t| result.t_stats[(0, t)].abs())
2474            .fold(0.0_f64, f64::max);
2475        assert!(
2476            max_abs_t > 0.5,
2477            "expected a noticeable t-stat somewhere on the grid, got max |t|={max_abs_t}"
2478        );
2479    }
2480
2481    // -----------------------------------------------------------------------
2482    // REG-05-L: fast_fmm empty-data error path (WR-04)
2483    // -----------------------------------------------------------------------
2484
2485    #[test]
2486    fn test_fast_fmm_empty_data_error() {
2487        let empty = FdMatrix::zeros(0, 0);
2488        let cfg = FastFmmConfig::default();
2489        let err = fast_fmm(&empty, &[], None, &cfg).unwrap_err();
2490        match err {
2491            FdarError::InvalidDimension { parameter, .. } => {
2492                assert_eq!(parameter, "data");
2493            }
2494            other => panic!("Expected InvalidDimension for data, got {:?}", other),
2495        }
2496    }
2497
2498    // -----------------------------------------------------------------------
2499    // CR-01: fast_fmm max_iter actually takes effect
2500    // -----------------------------------------------------------------------
2501
2502    #[test]
2503    fn test_fast_fmm_max_iter_takes_effect() {
2504        let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 20);
2505        // A very tight 1-iteration run should yield different variance estimates
2506        // than a well-converged 100-iteration run.
2507        let cfg_tight = FastFmmConfig {
2508            max_iter: 1,
2509            tol: 1e-30,
2510            compute_inference: false,
2511            ..Default::default()
2512        };
2513        let cfg_full = FastFmmConfig {
2514            max_iter: 100,
2515            tol: 1e-10,
2516            compute_inference: false,
2517            ..Default::default()
2518        };
2519        let r1 = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg_tight).unwrap();
2520        let r2 = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg_full).unwrap();
2521        // sigma2_eps at some grid points should differ between 1-iter and 100-iter.
2522        let same = r1
2523            .sigma2_eps
2524            .iter()
2525            .zip(&r2.sigma2_eps)
2526            .all(|(a, b)| (a - b).abs() < 1e-12);
2527        assert!(
2528            !same,
2529            "1-iter and 100-iter fast_fmm should produce different sigma2_eps (max_iter is now wired)"
2530        );
2531    }
2532
2533    // -----------------------------------------------------------------------
2534    // WR-01: even smooth_window is rounded up to nearest odd
2535    // -----------------------------------------------------------------------
2536
2537    #[test]
2538    fn test_fast_fmm_even_smooth_window() {
2539        let (data, subject_ids, covariates, _t) = generate_fmm_data(6, 3, 15);
2540        // Even window (4) — should not error, and produces finite results.
2541        let cfg_even = FastFmmConfig {
2542            smooth_window: 4,
2543            compute_inference: false,
2544            ..Default::default()
2545        };
2546        let result = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg_even).unwrap();
2547        assert_eq!(result.n_grid, 15);
2548        for j in 0..result.beta_matrix.nrows() {
2549            for t in 0..result.beta_matrix.ncols() {
2550                assert!(result.beta_matrix[(j, t)].is_finite());
2551            }
2552        }
2553        // Odd window (5) should produce the same result as even 4 (rounded up to 5).
2554        let cfg_odd = FastFmmConfig {
2555            smooth_window: 5,
2556            compute_inference: false,
2557            ..Default::default()
2558        };
2559        let result_odd = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg_odd).unwrap();
2560        for j in 0..result.beta_matrix.nrows() {
2561            for t in 0..result.beta_matrix.ncols() {
2562                assert!(
2563                    (result.beta_matrix[(j, t)] - result_odd.beta_matrix[(j, t)]).abs() < 1e-12,
2564                    "even window 4 should produce identical output to odd window 5 (rounded up)"
2565                );
2566            }
2567        }
2568    }
2569
2570    // -----------------------------------------------------------------------
2571    // WR-02: random_slopes = true returns InvalidParameter
2572    // -----------------------------------------------------------------------
2573
2574    #[test]
2575    fn test_dense_flmm_random_slopes_errors() {
2576        let (data, subject_ids, covariates, _t) = generate_fmm_data(6, 2, 15);
2577        let cfg = DenseFlmmConfig {
2578            random_slopes: true,
2579            ..Default::default()
2580        };
2581        let err = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap_err();
2582        match err {
2583            FdarError::InvalidParameter { parameter, .. } => {
2584                assert_eq!(parameter, "random_slopes");
2585            }
2586            other => panic!(
2587                "Expected InvalidParameter for random_slopes, got {:?}",
2588                other
2589            ),
2590        }
2591    }
2592
2593    // -----------------------------------------------------------------------
2594    // WR-03: max_iter == 0 returns InvalidParameter for dense_flmm and fast_fmm
2595    // -----------------------------------------------------------------------
2596
2597    #[test]
2598    fn test_dense_flmm_max_iter_zero_errors() {
2599        let (data, subject_ids, _cov, _t) = generate_fmm_data(4, 2, 10);
2600        let cfg = DenseFlmmConfig {
2601            max_iter: 0,
2602            ..Default::default()
2603        };
2604        let err = dense_flmm(&data, &subject_ids, None, &cfg).unwrap_err();
2605        match err {
2606            FdarError::InvalidParameter { parameter, .. } => {
2607                assert_eq!(parameter, "max_iter");
2608            }
2609            other => panic!("Expected InvalidParameter for max_iter, got {:?}", other),
2610        }
2611    }
2612
2613    #[test]
2614    fn test_fast_fmm_max_iter_zero_errors() {
2615        let (data, subject_ids, _cov, _t) = generate_fmm_data(4, 2, 10);
2616        let cfg = FastFmmConfig {
2617            max_iter: 0,
2618            ..Default::default()
2619        };
2620        let err = fast_fmm(&data, &subject_ids, None, &cfg).unwrap_err();
2621        match err {
2622            FdarError::InvalidParameter { parameter, .. } => {
2623                assert_eq!(parameter, "max_iter");
2624            }
2625            other => panic!("Expected InvalidParameter for max_iter, got {:?}", other),
2626        }
2627    }
2628}