Skip to main content

fdars_core/
fof_regression.rs

1//! Function-on-function regression.
2//!
3//! Model: `Y(s) = α(s) + ∫ β(s,t) X(t) dt + ε(s)`
4//!
5//! Uses double FPCA: decompose both response Y and predictor X into
6//! FPC scores, regress Y-scores on X-scores, then reconstruct β(s,t)
7//! and fitted curves.
8//!
9//! # References
10//!
11//! - Ramsay, J. O. & Silverman, B. W. (2005). *Functional Data Analysis*, Ch. 16-17.
12//! - Yao, F., Müller, H.-G. & Wang, J.-L. (2005). Functional linear regression
13//!   analysis for longitudinal data. *Annals of Statistics*, 33(6), 2873--2903.
14//! - Ivanescu, A. E., Staicu, A.-M., Scheipl, F. & Greven, S. (2015).
15//!   Penalized function-on-function regression. *Computational Statistics*,
16//!   30(2), 539--568.
17
18use crate::error::FdarError;
19use crate::linalg::{cholesky_factor, cholesky_forward_back, compute_xtx};
20use crate::matrix::FdMatrix;
21use crate::regression::{fdata_to_pc_1d, FpcaResult};
22
23// ---------------------------------------------------------------------------
24// Result type
25// ---------------------------------------------------------------------------
26
27/// Result of function-on-function regression.
28#[derive(Debug, Clone, PartialEq)]
29#[non_exhaustive]
30pub struct FofResult {
31    /// Intercept function α(s) (length m_y)
32    pub intercept: Vec<f64>,
33    /// Coefficient surface β(s,t) stored as (m_y x m_x) matrix
34    pub beta_surface: FdMatrix,
35    /// Fitted response curves (n x m_y)
36    pub fitted: FdMatrix,
37    /// Residual curves (n x m_y)
38    pub residuals: FdMatrix,
39    /// R-squared per response grid point (length m_y)
40    pub r_squared_t: Vec<f64>,
41    /// Overall R-squared (mean of pointwise R-squared)
42    pub r_squared: f64,
43    /// Number of predictor FPC components used
44    pub ncomp_x: usize,
45    /// Number of response FPC components used
46    pub ncomp_y: usize,
47    /// FPCA of predictor (for projection)
48    pub fpca_x: FpcaResult,
49    /// FPCA of response (for reconstruction)
50    pub fpca_y: FpcaResult,
51    /// Coefficient matrix B: Y-scores = X-scores * B (ncomp_x x ncomp_y)
52    pub coef_matrix: FdMatrix,
53}
54
55// ---------------------------------------------------------------------------
56// Main function
57// ---------------------------------------------------------------------------
58
59/// Function-on-function regression via double FPCA.
60///
61/// Decomposes both predictor and response via FPCA, regresses Y-scores
62/// on X-scores using OLS, then reconstructs the coefficient surface
63/// β(s,t) and fitted curves.
64///
65/// # Arguments
66/// * `x_data` - Functional predictor (n x m_x)
67/// * `y_data` - Functional response (n x m_y)
68/// * `x_argvals` - Predictor grid (length m_x)
69/// * `y_argvals` - Response grid (length m_y)
70/// * `ncomp_x` - Number of predictor FPC components
71/// * `ncomp_y` - Number of response FPC components
72///
73/// # Errors
74///
75/// Returns [`FdarError::InvalidDimension`] if `x_data` and `y_data` have
76/// different row counts, or argvals lengths do not match column counts.
77/// Returns [`FdarError::InvalidParameter`] if `ncomp_x` or `ncomp_y` is zero.
78/// Returns [`FdarError::ComputationFailed`] if FPCA or OLS fails.
79///
80/// # References
81///
82/// Ramsay, J. O. & Silverman, B. W. (2005). *Functional Data Analysis*, Ch. 16-17.
83///
84/// # Examples
85///
86/// ```
87/// use fdars_core::matrix::FdMatrix;
88/// use fdars_core::fof_regression::fof_regression;
89///
90/// let (n, mx, my) = (25, 30, 20);
91/// let x = FdMatrix::from_column_major(
92///     (0..n * mx).map(|k| {
93///         let i = (k % n) as f64;
94///         let j = (k / n) as f64;
95///         ((i + 1.0) * j * 0.2).sin()
96///     }).collect(), n, mx,
97/// ).unwrap();
98/// let y = FdMatrix::from_column_major(
99///     (0..n * my).map(|k| {
100///         let i = (k % n) as f64;
101///         let j = (k / n) as f64;
102///         0.5 * ((i + 1.0) * j * 0.15).cos()
103///     }).collect(), n, my,
104/// ).unwrap();
105/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
106/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
107///
108/// let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
109/// assert_eq!(fit.fitted.shape(), (n, my));
110/// assert_eq!(fit.beta_surface.shape(), (my, mx));
111/// ```
112#[must_use = "expensive computation whose result should not be discarded"]
113pub fn fof_regression(
114    x_data: &FdMatrix,
115    y_data: &FdMatrix,
116    x_argvals: &[f64],
117    y_argvals: &[f64],
118    ncomp_x: usize,
119    ncomp_y: usize,
120) -> Result<FofResult, FdarError> {
121    let (n_x, m_x) = x_data.shape();
122    let (n_y, m_y) = y_data.shape();
123
124    if n_x != n_y {
125        return Err(FdarError::InvalidDimension {
126            parameter: "y_data",
127            expected: format!("{n_x} rows (matching x_data)"),
128            actual: format!("{n_y} rows"),
129        });
130    }
131    let n = n_x;
132
133    if n < 3 {
134        return Err(FdarError::InvalidDimension {
135            parameter: "x_data",
136            expected: "at least 3 observations".to_string(),
137            actual: format!("{n}"),
138        });
139    }
140    if x_argvals.len() != m_x {
141        return Err(FdarError::InvalidDimension {
142            parameter: "x_argvals",
143            expected: format!("{m_x} elements"),
144            actual: format!("{} elements", x_argvals.len()),
145        });
146    }
147    if y_argvals.len() != m_y {
148        return Err(FdarError::InvalidDimension {
149            parameter: "y_argvals",
150            expected: format!("{m_y} elements"),
151            actual: format!("{} elements", y_argvals.len()),
152        });
153    }
154    if ncomp_x == 0 {
155        return Err(FdarError::InvalidParameter {
156            parameter: "ncomp_x",
157            message: "must be >= 1".to_string(),
158        });
159    }
160    if ncomp_y == 0 {
161        return Err(FdarError::InvalidParameter {
162            parameter: "ncomp_y",
163            message: "must be >= 1".to_string(),
164        });
165    }
166
167    let ncomp_x = ncomp_x.min(n - 1).min(m_x);
168    let ncomp_y = ncomp_y.min(n - 1).min(m_y);
169
170    // --- FPCA on X and Y ---
171    let fpca_x = fdata_to_pc_1d(x_data, ncomp_x, x_argvals)?;
172    let fpca_y = fdata_to_pc_1d(y_data, ncomp_y, y_argvals)?;
173
174    // --- Multivariate OLS: Y_scores = X_scores * B ---
175    // Use projected scores (weighted inner product with eigenfunctions) rather
176    // than SVD-derived scores so that training and prediction follow the same
177    // computational path, guaranteeing exact agreement on training data.
178    let x_scores = fpca_x.project(x_data)?;
179    let y_scores = fpca_y.project(y_data)?;
180
181    let mut xtx = compute_xtx(&x_scores);
182    // Small ridge regularization for numerical stability (standard in
183    // double-FPCA regression; see Ivanescu et al. 2015).
184    let ridge = 1e-8 * (0..ncomp_x).map(|k| xtx[k * ncomp_x + k]).sum::<f64>() / ncomp_x as f64;
185    for k in 0..ncomp_x {
186        xtx[k * ncomp_x + k] += ridge.max(1e-12);
187    }
188    let l = cholesky_factor(&xtx, ncomp_x)?;
189
190    // Solve for each column of Y_scores separately
191    let mut coef_matrix = FdMatrix::zeros(ncomp_x, ncomp_y);
192    for l_col in 0..ncomp_y {
193        // X' * y_scores[:,l_col]
194        let mut xty = vec![0.0; ncomp_x];
195        for k in 0..ncomp_x {
196            let mut s = 0.0;
197            for i in 0..n {
198                s += x_scores[(i, k)] * y_scores[(i, l_col)];
199            }
200            xty[k] = s;
201        }
202        let b_col = cholesky_forward_back(&l, &xty, ncomp_x);
203        for k in 0..ncomp_x {
204            coef_matrix[(k, l_col)] = b_col[k];
205        }
206    }
207
208    // --- Reconstruct coefficient surface β(s,t) ---
209    // β(s_i, t_j) = Σ_k Σ_l B_{kl} * φ_x^k(t_j) * φ_y^l(s_i)
210    let mut beta_surface = FdMatrix::zeros(m_y, m_x);
211    for si in 0..m_y {
212        for tj in 0..m_x {
213            let mut val = 0.0;
214            for k in 0..ncomp_x {
215                for l_col in 0..ncomp_y {
216                    val += coef_matrix[(k, l_col)]
217                        * fpca_x.rotation[(tj, k)]
218                        * fpca_y.rotation[(si, l_col)];
219                }
220            }
221            beta_surface[(si, tj)] = val;
222        }
223    }
224
225    // --- Fitted Y-scores and reconstruction ---
226    // Ŷ_scores = X_scores * B (n x ncomp_y)
227    let mut fitted_scores = FdMatrix::zeros(n, ncomp_y);
228    for i in 0..n {
229        for l_col in 0..ncomp_y {
230            let mut s = 0.0;
231            for k in 0..ncomp_x {
232                s += x_scores[(i, k)] * coef_matrix[(k, l_col)];
233            }
234            fitted_scores[(i, l_col)] = s;
235        }
236    }
237
238    // Reconstruct fitted curves: Ŷ(s) = mean_y(s) + Σ_l fitted_score_l * φ_y^l(s)
239    let mut fitted = FdMatrix::zeros(n, m_y);
240    for i in 0..n {
241        for j in 0..m_y {
242            let mut val = fpca_y.mean[j];
243            for l_col in 0..ncomp_y {
244                val += fitted_scores[(i, l_col)] * fpca_y.rotation[(j, l_col)];
245            }
246            fitted[(i, j)] = val;
247        }
248    }
249
250    // --- Residuals ---
251    let mut residuals = FdMatrix::zeros(n, m_y);
252    for i in 0..n {
253        for j in 0..m_y {
254            residuals[(i, j)] = y_data[(i, j)] - fitted[(i, j)];
255        }
256    }
257
258    // --- Intercept: α(s) = mean_y(s) (since we centered Y via FPCA) ---
259    let intercept = fpca_y.mean.clone();
260
261    // --- Pointwise R² ---
262    let mut r_squared_t = vec![0.0; m_y];
263    for j in 0..m_y {
264        let y_mean_j = fpca_y.mean[j];
265        let mut ss_tot = 0.0;
266        let mut ss_res = 0.0;
267        for i in 0..n {
268            ss_tot += (y_data[(i, j)] - y_mean_j).powi(2);
269            ss_res += residuals[(i, j)].powi(2);
270        }
271        r_squared_t[j] = if ss_tot > 0.0 {
272            1.0 - ss_res / ss_tot
273        } else {
274            0.0
275        };
276    }
277
278    let r_squared = r_squared_t.iter().sum::<f64>() / m_y as f64;
279
280    Ok(FofResult {
281        intercept,
282        beta_surface,
283        fitted,
284        residuals,
285        r_squared_t,
286        r_squared,
287        ncomp_x,
288        ncomp_y,
289        fpca_x,
290        fpca_y,
291        coef_matrix,
292    })
293}
294
295// ---------------------------------------------------------------------------
296// Prediction
297// ---------------------------------------------------------------------------
298
299/// Predict functional responses from new functional predictors.
300///
301/// Projects `new_x` onto the predictor FPCA, computes predicted Y-scores
302/// via the fitted coefficient matrix, and reconstructs response curves.
303///
304/// # Arguments
305/// * `fit` - A fitted [`FofResult`]
306/// * `new_x` - New functional predictor data (n_new x m_x)
307///
308/// # Errors
309///
310/// Returns [`FdarError::InvalidDimension`] if the column count of `new_x`
311/// does not match the predictor grid used during fitting.
312///
313/// # Examples
314///
315/// ```
316/// use fdars_core::matrix::FdMatrix;
317/// use fdars_core::fof_regression::{fof_regression, predict_fof};
318///
319/// let (n, mx, my) = (25, 30, 20);
320/// let x = FdMatrix::from_column_major(
321///     (0..n * mx).map(|k| {
322///         let i = (k % n) as f64;
323///         let j = (k / n) as f64;
324///         ((i + 1.0) * j * 0.2).sin()
325///     }).collect(), n, mx,
326/// ).unwrap();
327/// let y = FdMatrix::from_column_major(
328///     (0..n * my).map(|k| {
329///         let i = (k % n) as f64;
330///         let j = (k / n) as f64;
331///         0.5 * ((i + 1.0) * j * 0.15).cos()
332///     }).collect(), n, my,
333/// ).unwrap();
334/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
335/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
336///
337/// let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
338/// let predicted = predict_fof(&fit, &x).unwrap();
339/// assert_eq!(predicted.shape(), (n, my));
340/// ```
341pub fn predict_fof(fit: &FofResult, new_x: &FdMatrix) -> Result<FdMatrix, FdarError> {
342    let (n_new, _m_x) = new_x.shape();
343
344    // Project onto predictor FPCA
345    let x_scores = fit.fpca_x.project(new_x)?;
346
347    let ncomp_x = fit.ncomp_x;
348    let ncomp_y = fit.ncomp_y;
349    let m_y = fit.fpca_y.mean.len();
350
351    // Compute predicted Y-scores: Ŷ_scores = X_scores * B
352    let mut pred_scores = FdMatrix::zeros(n_new, ncomp_y);
353    for i in 0..n_new {
354        for l_col in 0..ncomp_y {
355            let mut s = 0.0;
356            for k in 0..ncomp_x {
357                s += x_scores[(i, k)] * fit.coef_matrix[(k, l_col)];
358            }
359            pred_scores[(i, l_col)] = s;
360        }
361    }
362
363    // Reconstruct: Ŷ(s) = mean_y(s) + Σ_l score_l * φ_y^l(s)
364    let mut predicted = FdMatrix::zeros(n_new, m_y);
365    for i in 0..n_new {
366        for j in 0..m_y {
367            let mut val = fit.fpca_y.mean[j];
368            for l_col in 0..ncomp_y {
369                val += pred_scores[(i, l_col)] * fit.fpca_y.rotation[(j, l_col)];
370            }
371            predicted[(i, j)] = val;
372        }
373    }
374
375    Ok(predicted)
376}
377
378// ---------------------------------------------------------------------------
379// Tests
380// ---------------------------------------------------------------------------
381// Cross-validation
382// ---------------------------------------------------------------------------
383
384/// Result of function-on-function cross-validation.
385#[derive(Debug, Clone, PartialEq)]
386#[non_exhaustive]
387pub struct FofCvResult {
388    /// (ncomp_x, ncomp_y) candidates tested.
389    pub candidates: Vec<(usize, usize)>,
390    /// Integrated CV-MSE for each candidate.
391    pub cv_errors: Vec<f64>,
392    /// Optimal (ncomp_x, ncomp_y).
393    pub optimal: (usize, usize),
394    /// Minimum integrated CV-MSE.
395    pub min_cv_mse: f64,
396}
397
398/// K-fold cross-validation for function-on-function regression.
399///
400/// Searches over a grid of (ncomp_x, ncomp_y) values and selects the
401/// combination minimizing integrated mean squared error (IMSE).
402///
403/// # Arguments
404/// * `x_data` - Functional predictor (n × m_x)
405/// * `y_data` - Functional response (n × m_y)
406/// * `x_argvals` - Predictor grid (length m_x)
407/// * `y_argvals` - Response grid (length m_y)
408/// * `ncomp_x_max` - Maximum predictor components to try
409/// * `ncomp_y_max` - Maximum response components to try
410/// * `n_folds` - Number of CV folds
411/// * `seed` - Random seed for fold assignment
412///
413/// # References
414///
415/// Ivanescu, A. E., Staicu, A.-M., Scheipl, F. & Greven, S. (2015).
416/// Penalized function-on-function regression. *Computational Statistics*,
417/// 30(2), 539--568.
418#[must_use = "expensive computation whose result should not be discarded"]
419pub fn fof_cv(
420    x_data: &FdMatrix,
421    y_data: &FdMatrix,
422    x_argvals: &[f64],
423    y_argvals: &[f64],
424    ncomp_x_max: usize,
425    ncomp_y_max: usize,
426    n_folds: usize,
427    seed: u64,
428) -> Result<FofCvResult, FdarError> {
429    let n = x_data.nrows();
430    if n < n_folds {
431        return Err(FdarError::InvalidDimension {
432            parameter: "x_data",
433            expected: format!("at least {n_folds} rows"),
434            actual: format!("{n}"),
435        });
436    }
437
438    let folds = crate::cv::create_folds(n, n_folds, seed);
439    let ncomp_x_max = ncomp_x_max.min(n - 2);
440    let ncomp_y_max = ncomp_y_max.min(n - 2);
441    let m_y = y_data.ncols();
442
443    // Integration weights for IMSE
444    let y_weights = crate::helpers::simpsons_weights(y_argvals);
445
446    let mut candidates = Vec::new();
447    let mut cv_errors = Vec::new();
448    let mut best = (1, 1);
449    let mut best_mse = f64::INFINITY;
450
451    for ncx in 1..=ncomp_x_max {
452        for ncy in 1..=ncomp_y_max {
453            let mut total_imse = 0.0;
454            let mut count = 0;
455
456            for fold in 0..n_folds {
457                let train_idx: Vec<usize> = (0..n).filter(|&i| folds[i] != fold).collect();
458                let test_idx: Vec<usize> = (0..n).filter(|&i| folds[i] == fold).collect();
459                let n_test = test_idx.len();
460                if n_test == 0 || train_idx.len() < ncx.max(ncy) + 2 {
461                    continue;
462                }
463
464                let train_x = x_data.select_rows(&train_idx);
465                let train_y = y_data.select_rows(&train_idx);
466                let test_x = x_data.select_rows(&test_idx);
467                let test_y = y_data.select_rows(&test_idx);
468
469                let Ok(fit) = fof_regression(&train_x, &train_y, x_argvals, y_argvals, ncx, ncy)
470                else {
471                    continue;
472                };
473
474                let Ok(predicted) = predict_fof(&fit, &test_x) else {
475                    continue;
476                };
477
478                // Integrated MSE per test curve
479                for ti in 0..n_test {
480                    let imse: f64 = (0..m_y)
481                        .map(|j| (test_y[(ti, j)] - predicted[(ti, j)]).powi(2) * y_weights[j])
482                        .sum();
483                    total_imse += imse;
484                    count += 1;
485                }
486            }
487
488            let mse = if count > 0 {
489                total_imse / count as f64
490            } else {
491                f64::INFINITY
492            };
493
494            candidates.push((ncx, ncy));
495            cv_errors.push(mse);
496
497            if mse < best_mse {
498                best_mse = mse;
499                best = (ncx, ncy);
500            }
501        }
502    }
503
504    if candidates.is_empty() {
505        return Err(FdarError::ComputationFailed {
506            operation: "fof_cv",
507            detail: "no valid (ncomp_x, ncomp_y) produced CV errors".into(),
508        });
509    }
510
511    Ok(FofCvResult {
512        candidates,
513        cv_errors,
514        optimal: best,
515        min_cv_mse: best_mse,
516    })
517}
518
519// ---------------------------------------------------------------------------
520// Random-effects function-on-function regression
521// ---------------------------------------------------------------------------
522
523/// Configuration for [`fof_re_regression`].
524///
525/// No `#[non_exhaustive]` — callers may construct with struct literals.
526#[derive(Debug, Clone, PartialEq)]
527#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
528pub struct FofReConfig {
529    /// Number of predictor FPC components (default: 3)
530    pub ncomp_x: usize,
531    /// Number of response FPC components (default: 3)
532    pub ncomp_y: usize,
533    /// Maximum REML EM iterations per Y-score component (default: 50)
534    pub max_iter: usize,
535    /// Convergence tolerance for variance components (default: 1e-10)
536    pub tol: f64,
537}
538
539impl Default for FofReConfig {
540    fn default() -> Self {
541        Self {
542            ncomp_x: 3,
543            ncomp_y: 3,
544            max_iter: 50,
545            tol: 1e-10,
546        }
547    }
548}
549
550/// Result of a random-effects function-on-function regression.
551///
552/// Extends [`FofResult`] with subject-level random intercepts on Y-score
553/// components, enabling prediction for grouped/longitudinal functional data.
554///
555/// # Divergence from `refund::pffr`
556///
557/// R's `pffr(y ~ pcre(x))` uses a penalized-spline GAMM (mgcv) backend with
558/// functional random effects represented in a spline basis. `fof_re_regression`
559/// instead uses the FPC-score parametrization: both X and Y are decomposed into
560/// FPC scores (`fdata_to_pc_1d`), and for each Y-score component a scalar linear
561/// mixed model (REML EM, reusing `famm::fit_scalar_mixed_model`) is fitted with
562/// the X-scores as fixed-effect covariates and subject IDs as the grouping factor.
563/// No basis penalties are applied — this matches the locked design decision for
564/// Phase 32.
565#[derive(Debug, Clone, PartialEq)]
566#[non_exhaustive]
567#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
568pub struct FofReResult {
569    /// Intercept function α(s) = mean_y(s) (length m_y)
570    pub intercept: Vec<f64>,
571    /// Coefficient surface β(s,t) stored as (m_y × m_x) matrix
572    pub beta_surface: FdMatrix,
573    /// Fitted response curves (n × m_y), including fixed + random effects
574    pub fitted: FdMatrix,
575    /// Residual curves (n × m_y)
576    pub residuals: FdMatrix,
577    /// R-squared per response grid point (length m_y)
578    pub r_squared_t: Vec<f64>,
579    /// Overall R-squared (mean of pointwise R-squared)
580    pub r_squared: f64,
581    /// Number of predictor FPC components used
582    pub ncomp_x: usize,
583    /// Number of response FPC components used
584    pub ncomp_y: usize,
585    /// FPCA of predictor (carried for prediction)
586    pub fpca_x: FpcaResult,
587    /// FPCA of response (carried for reconstruction)
588    pub fpca_y: FpcaResult,
589    /// Fixed-effect coefficient matrix B (ncomp_x × ncomp_y):
590    /// Y-scores ≈ X-scores * B
591    pub coef_matrix: FdMatrix,
592    /// Subject-level random intercept functions (n_subjects × m_y)
593    pub random_effects: FdMatrix,
594    /// Per-Y-component random-intercept variance (length ncomp_y)
595    pub sigma2_u: Vec<f64>,
596    /// Mean residual variance across Y-score components
597    pub sigma2_eps: f64,
598    /// Number of unique subjects
599    pub n_subjects: usize,
600}
601
602/// Flexible random-effects function-on-function regression via double FPCA.
603///
604/// Extends [`fof_regression`] by fitting a scalar linear mixed model (REML EM)
605/// for each Y-score component, adding subject-level random intercepts. This
606/// captures within-subject correlation in grouped/longitudinal functional data.
607///
608/// # Algorithm
609///
610/// 1. **Double FPCA** (same as `fof_regression`): decompose X and Y into FPC
611///    scores via `fdata_to_pc_1d`.
612/// 2. **Per-Y-score mixed model** (new): for each Y-score component `l`,
613///    fit `y_scores[:,l] = x_scores * γ_l + u_{subj(i),l} + ε_il` using
614///    `famm::fit_scalar_mixed_model`. X-scores are passed directly as
615///    covariates without rescaling (Pitfall 2: projection already carries
616///    the L² weighting — do NOT re-apply `h.sqrt()` normalization).
617/// 3. **Reconstruction** (same structure as `fof_regression`): build
618///    `beta_surface` from `coef_matrix`; fitted curves add mean_y + fixed
619///    X-score contribution + subject random-effect contribution;
620///    `random_effects` reconstructed via `famm::recover_random_effects`.
621///
622/// # Arguments
623/// * `x_data` - Functional predictor (n × m_x)
624/// * `y_data` - Functional response (n × m_y)
625/// * `subject_ids` - Subject identifier for each observation (length n)
626/// * `x_argvals` - Predictor evaluation grid (length m_x)
627/// * `y_argvals` - Response evaluation grid (length m_y)
628/// * `config` - [`FofReConfig`] with component counts and convergence settings
629///
630/// # Errors
631///
632/// Returns [`FdarError::InvalidDimension`] if:
633/// - `x_data` and `y_data` have different row counts
634/// - fewer than 3 observations
635/// - argvals lengths do not match column counts
636/// - `subject_ids.len()` does not equal `n`
637///
638/// Returns [`FdarError::InvalidParameter`] if `ncomp_x` or `ncomp_y` is zero.
639/// Returns [`FdarError::ComputationFailed`] if FPCA fails.
640///
641/// # Examples
642///
643/// ```
644/// use fdars_core::matrix::FdMatrix;
645/// use fdars_core::fof_regression::{fof_re_regression, FofReConfig};
646///
647/// let (n, mx, my) = (20, 25, 15);
648/// let n_subjects = 5;
649/// let x = FdMatrix::from_column_major(
650///     (0..n * mx).map(|k| {
651///         let i = (k % n) as f64;
652///         let j = (k / n) as f64;
653///         ((i + 1.0) * j * 0.2).sin()
654///     }).collect(), n, mx,
655/// ).unwrap();
656/// let y = FdMatrix::from_column_major(
657///     (0..n * my).map(|k| {
658///         let i = (k % n) as f64;
659///         let j = (k / n) as f64;
660///         0.5 * ((i + 1.0) * j * 0.15).cos()
661///     }).collect(), n, my,
662/// ).unwrap();
663/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
664/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
665/// // 4 visits per subject
666/// let subject_ids: Vec<usize> = (0..n).map(|i| i / 4).collect();
667///
668/// let config = FofReConfig::default();
669/// let fit = fof_re_regression(&x, &y, &subject_ids, &tx, &ty, &config).unwrap();
670/// assert_eq!(fit.fitted.shape(), (n, my));
671/// assert_eq!(fit.beta_surface.shape(), (my, mx));
672/// assert_eq!(fit.random_effects.nrows(), n_subjects);
673/// ```
674#[must_use = "expensive computation whose result should not be discarded"]
675pub fn fof_re_regression(
676    x_data: &FdMatrix,
677    y_data: &FdMatrix,
678    subject_ids: &[usize],
679    x_argvals: &[f64],
680    y_argvals: &[f64],
681    config: &FofReConfig,
682) -> Result<FofReResult, FdarError> {
683    let (n_x, m_x) = x_data.shape();
684    let (n_y, m_y) = y_data.shape();
685
686    // --- Input validation ---
687    if n_x != n_y {
688        return Err(FdarError::InvalidDimension {
689            parameter: "y_data",
690            expected: format!("{n_x} rows (matching x_data)"),
691            actual: format!("{n_y} rows"),
692        });
693    }
694    let n = n_x;
695
696    if n < 3 {
697        return Err(FdarError::InvalidDimension {
698            parameter: "x_data",
699            expected: "at least 3 observations".to_string(),
700            actual: format!("{n}"),
701        });
702    }
703    if x_argvals.len() != m_x {
704        return Err(FdarError::InvalidDimension {
705            parameter: "x_argvals",
706            expected: format!("{m_x} elements"),
707            actual: format!("{} elements", x_argvals.len()),
708        });
709    }
710    if y_argvals.len() != m_y {
711        return Err(FdarError::InvalidDimension {
712            parameter: "y_argvals",
713            expected: format!("{m_y} elements"),
714            actual: format!("{} elements", y_argvals.len()),
715        });
716    }
717    if subject_ids.len() != n {
718        return Err(FdarError::InvalidDimension {
719            parameter: "subject_ids",
720            expected: format!("length {n}"),
721            actual: format!("length {}", subject_ids.len()),
722        });
723    }
724    if config.ncomp_x == 0 {
725        return Err(FdarError::InvalidParameter {
726            parameter: "ncomp_x",
727            message: "must be >= 1".to_string(),
728        });
729    }
730    if config.ncomp_y == 0 {
731        return Err(FdarError::InvalidParameter {
732            parameter: "ncomp_y",
733            message: "must be >= 1".to_string(),
734        });
735    }
736
737    let ncomp_x = config.ncomp_x.min(n - 1).min(m_x);
738    let ncomp_y = config.ncomp_y.min(n - 1).min(m_y);
739
740    // --- Step 1: Double FPCA ---
741    let fpca_x = fdata_to_pc_1d(x_data, ncomp_x, x_argvals)?;
742    let fpca_y = fdata_to_pc_1d(y_data, ncomp_y, y_argvals)?;
743
744    // Project to score space using L²-weighted inner product
745    let x_scores = fpca_x.project(x_data)?;
746    let y_scores = fpca_y.project(y_data)?;
747
748    // Build subject structure (non-contiguous IDs handled by build_subject_map)
749    let (subject_map, n_subjects) = crate::famm::build_subject_map(subject_ids);
750
751    // Wrap x_scores as covariates for fit_scalar_mixed_model.
752    // INTENTIONAL: x_scores are passed directly WITHOUT re-applying h.sqrt() normalization.
753    // The L² weighting is already embedded in fpca_x.project(); re-scaling would double-count
754    // it (Pitfall 2, per Phase 32 RESEARCH.md). This diverges from fit_all_components in
755    // famm.rs which applies score_scale = h.sqrt() before calling fit_scalar_mixed_model.
756    let p = ncomp_x; // number of fixed-effect covariates per Y-score model
757
758    // --- Step 2: Per-Y-score mixed model ---
759    let mut coef_matrix = FdMatrix::zeros(ncomp_x, ncomp_y);
760    // u_hat_per_component[l] = Vec of length n_subjects (random intercepts for Y-score l)
761    let mut u_hat_per_component: Vec<Vec<f64>> = Vec::with_capacity(ncomp_y);
762    let mut sigma2_u = vec![0.0; ncomp_y];
763    let mut sigma2_eps_total = 0.0_f64;
764
765    for l in 0..ncomp_y {
766        // Extract y_scores column l into a Vec<f64>
767        let y_scores_l: Vec<f64> = (0..n).map(|i| y_scores[(i, l)]).collect();
768
769        let result = crate::famm::fit_scalar_mixed_model(
770            &y_scores_l,
771            &subject_map,
772            n_subjects,
773            Some(&x_scores),
774            p,
775        );
776
777        // gamma_l: fixed-effect coefficients (length ncomp_x)
778        for k in 0..ncomp_x {
779            if k < result.gamma.len() {
780                coef_matrix[(k, l)] = result.gamma[k];
781            }
782        }
783        u_hat_per_component.push(result.u_hat);
784        sigma2_u[l] = result.sigma2_u;
785        sigma2_eps_total += result.sigma2_eps;
786    }
787    let sigma2_eps = sigma2_eps_total / ncomp_y as f64;
788
789    // --- Step 3: Reconstruction ---
790
791    // Reconstruct β(s,t) surface
792    let mut beta_surface = FdMatrix::zeros(m_y, m_x);
793    for si in 0..m_y {
794        for tj in 0..m_x {
795            let mut val = 0.0;
796            for k in 0..ncomp_x {
797                for l in 0..ncomp_y {
798                    val +=
799                        coef_matrix[(k, l)] * fpca_x.rotation[(tj, k)] * fpca_y.rotation[(si, l)];
800                }
801            }
802            beta_surface[(si, tj)] = val;
803        }
804    }
805
806    // recover_random_effects expects u_hat[subject][component].
807    // u_hat_per_component is organized as [component][subject], so transpose.
808    let mut u_hat_by_subject: Vec<Vec<f64>> = vec![vec![0.0; ncomp_y]; n_subjects];
809    for l in 0..ncomp_y {
810        for s in 0..n_subjects {
811            u_hat_by_subject[s][l] = u_hat_per_component[l][s];
812        }
813    }
814    // Reconstruct random effects: n_subjects × m_y
815    // random_effects[s, j] = Σ_l u_hat_by_subject[s][l] * phi_y^l(j)
816    let random_effects = crate::famm::recover_random_effects(
817        &u_hat_by_subject,
818        &fpca_y.rotation,
819        n_subjects,
820        m_y,
821        ncomp_y,
822    );
823
824    // Compute fitted curves: mean_y + fixed-effect contribution + subject random effect
825    let mut fitted = FdMatrix::zeros(n, m_y);
826    for i in 0..n {
827        let s = subject_map[i];
828        for j in 0..m_y {
829            let mut val = fpca_y.mean[j];
830            // Fixed-effect contribution: Σ_l (Σ_k x_scores[i,k] * gamma[k,l]) * phi_y^l(j)
831            for l in 0..ncomp_y {
832                let mut score_l = 0.0;
833                for k in 0..ncomp_x {
834                    score_l += x_scores[(i, k)] * coef_matrix[(k, l)];
835                }
836                val += score_l * fpca_y.rotation[(j, l)];
837            }
838            // Random-effect contribution: b_s(j) = Σ_l u_hat[l][s] * phi_y^l(j)
839            val += random_effects[(s, j)];
840            fitted[(i, j)] = val;
841        }
842    }
843
844    // Residuals
845    let mut residuals = FdMatrix::zeros(n, m_y);
846    for i in 0..n {
847        for j in 0..m_y {
848            residuals[(i, j)] = y_data[(i, j)] - fitted[(i, j)];
849        }
850    }
851
852    // Intercept: α(s) = mean_y(s)
853    let intercept = fpca_y.mean.clone();
854
855    // Pointwise R²
856    let mut r_squared_t = vec![0.0; m_y];
857    for j in 0..m_y {
858        let y_mean_j = fpca_y.mean[j];
859        let mut ss_tot = 0.0;
860        let mut ss_res = 0.0;
861        for i in 0..n {
862            ss_tot += (y_data[(i, j)] - y_mean_j).powi(2);
863            ss_res += residuals[(i, j)].powi(2);
864        }
865        r_squared_t[j] = if ss_tot > 0.0 {
866            1.0 - ss_res / ss_tot
867        } else {
868            0.0
869        };
870    }
871    let r_squared = r_squared_t.iter().sum::<f64>() / m_y as f64;
872
873    Ok(FofReResult {
874        intercept,
875        beta_surface,
876        fitted,
877        residuals,
878        r_squared_t,
879        r_squared,
880        ncomp_x,
881        ncomp_y,
882        fpca_x,
883        fpca_y,
884        coef_matrix,
885        random_effects,
886        sigma2_u,
887        sigma2_eps,
888        n_subjects,
889    })
890}
891
892// ---------------------------------------------------------------------------
893// Prediction for random-effects FoF model
894// ---------------------------------------------------------------------------
895
896/// Predict functional responses from new functional predictors using a fitted
897/// [`FofReResult`].
898///
899/// Prediction is **fixed-effect only** — no random effects are added for
900/// unseen subjects. This mirrors the `fmm_predict` / `predict_fof` convention:
901/// new subjects receive only the population-level fixed-effect prediction.
902///
903/// # Arguments
904/// * `fit` - A fitted [`FofReResult`]
905/// * `new_x` - New functional predictor data (n_new × m_x)
906///
907/// # Errors
908///
909/// Returns [`FdarError::InvalidDimension`] if the column count of `new_x`
910/// does not match the predictor grid used during fitting.
911///
912/// # Examples
913///
914/// ```
915/// use fdars_core::matrix::FdMatrix;
916/// use fdars_core::fof_regression::{fof_re_regression, predict_fof_re, FofReConfig};
917///
918/// let (n, mx, my) = (20, 25, 15);
919/// let x = FdMatrix::from_column_major(
920///     (0..n * mx).map(|k| {
921///         let i = (k % n) as f64;
922///         let j = (k / n) as f64;
923///         ((i + 1.0) * j * 0.2).sin()
924///     }).collect(), n, mx,
925/// ).unwrap();
926/// let y = FdMatrix::from_column_major(
927///     (0..n * my).map(|k| {
928///         let i = (k % n) as f64;
929///         let j = (k / n) as f64;
930///         0.5 * ((i + 1.0) * j * 0.15).cos()
931///     }).collect(), n, my,
932/// ).unwrap();
933/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
934/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
935/// let subject_ids: Vec<usize> = (0..n).map(|i| i / 4).collect();
936///
937/// let config = FofReConfig::default();
938/// let fit = fof_re_regression(&x, &y, &subject_ids, &tx, &ty, &config).unwrap();
939/// let predicted = predict_fof_re(&fit, &x).unwrap();
940/// assert_eq!(predicted.shape(), (n, my));
941/// ```
942#[must_use = "prediction result should not be discarded"]
943pub fn predict_fof_re(fit: &FofReResult, new_x: &FdMatrix) -> Result<FdMatrix, FdarError> {
944    let (n_new, _m_x) = new_x.shape();
945
946    // Project onto predictor FPCA
947    let x_scores = fit.fpca_x.project(new_x)?;
948
949    let ncomp_x = fit.ncomp_x;
950    let ncomp_y = fit.ncomp_y;
951    let m_y = fit.fpca_y.mean.len();
952
953    // Compute predicted Y-scores: Ŷ_scores = X_scores * coef_matrix (fixed effect only)
954    let mut pred_scores = FdMatrix::zeros(n_new, ncomp_y);
955    for i in 0..n_new {
956        for l in 0..ncomp_y {
957            let mut s = 0.0;
958            for k in 0..ncomp_x {
959                s += x_scores[(i, k)] * fit.coef_matrix[(k, l)];
960            }
961            pred_scores[(i, l)] = s;
962        }
963    }
964
965    // Reconstruct: Ŷ(s) = mean_y(s) + Σ_l score_l * φ_y^l(s)
966    // No random effects for new (unseen) subjects — fixed-effect only prediction.
967    let mut predicted = FdMatrix::zeros(n_new, m_y);
968    for i in 0..n_new {
969        for j in 0..m_y {
970            let mut val = fit.fpca_y.mean[j];
971            for l in 0..ncomp_y {
972                val += pred_scores[(i, l)] * fit.fpca_y.rotation[(j, l)];
973            }
974            predicted[(i, j)] = val;
975        }
976    }
977
978    Ok(predicted)
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984    use std::f64::consts::PI;
985
986    /// Generate test data with multiple independent modes of variation so
987    /// that requesting several FPC components produces a well-conditioned
988    /// score matrix.
989    fn make_fof_data(
990        n: usize,
991        mx: usize,
992        my: usize,
993        seed: u64,
994    ) -> (FdMatrix, FdMatrix, Vec<f64>, Vec<f64>) {
995        let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1).max(1) as f64).collect();
996        let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1).max(1) as f64).collect();
997
998        let mut x = FdMatrix::zeros(n, mx);
999        let mut y = FdMatrix::zeros(n, my);
1000
1001        for i in 0..n {
1002            // Multiple independent per-observation loadings for X
1003            let a =
1004                ((seed.wrapping_mul(17).wrapping_add(i as u64 * 31) % 1000) as f64 / 500.0) - 1.0;
1005            let b =
1006                ((seed.wrapping_mul(7).wrapping_add(i as u64 * 53) % 1000) as f64 / 500.0) - 1.0;
1007            let c =
1008                ((seed.wrapping_mul(3).wrapping_add(i as u64 * 79) % 1000) as f64 / 500.0) - 1.0;
1009            for j in 0..mx {
1010                x[(i, j)] = a * (2.0 * PI * tx[j]).sin() + b * (4.0 * PI * tx[j]).cos() + c * tx[j];
1011            }
1012
1013            // Y depends on X via integral-like coupling with distinct modes
1014            for j in 0..my {
1015                y[(i, j)] = 1.5 * a * (2.0 * PI * ty[j]).cos() - 0.8 * b * (3.0 * PI * ty[j]).sin()
1016                    + 0.5 * c * ty[j].powi(2)
1017                    + 0.01 * (seed.wrapping_add(i as u64 + j as u64) % 10) as f64;
1018            }
1019        }
1020        (x, y, tx, ty)
1021    }
1022
1023    #[test]
1024    fn test_fof_regression_dimensions() {
1025        let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1026        let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1027
1028        assert_eq!(fit.fitted.shape(), (30, 25));
1029        assert_eq!(fit.residuals.shape(), (30, 25));
1030        assert_eq!(fit.beta_surface.shape(), (25, 40));
1031        assert_eq!(fit.intercept.len(), 25);
1032        assert_eq!(fit.r_squared_t.len(), 25);
1033        assert_eq!(fit.coef_matrix.shape(), (3, 3));
1034        assert_eq!(fit.ncomp_x, 3);
1035        assert_eq!(fit.ncomp_y, 3);
1036    }
1037
1038    #[test]
1039    fn test_fof_regression_r_squared_positive() {
1040        let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1041        let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1042
1043        // For correlated data, overall R² should be positive
1044        assert!(
1045            fit.r_squared > 0.0,
1046            "R² should be positive for correlated data, got {}",
1047            fit.r_squared
1048        );
1049    }
1050
1051    #[test]
1052    fn test_predict_fof_training_matches_fitted() {
1053        let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1054        let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1055        let predicted = predict_fof(&fit, &x).unwrap();
1056
1057        assert_eq!(predicted.shape(), fit.fitted.shape());
1058        let (n, my) = predicted.shape();
1059        for i in 0..n {
1060            for j in 0..my {
1061                assert!(
1062                    (predicted[(i, j)] - fit.fitted[(i, j)]).abs() < 1e-6,
1063                    "predicted should match fitted at ({i}, {j}): {} vs {}",
1064                    predicted[(i, j)],
1065                    fit.fitted[(i, j)]
1066                );
1067            }
1068        }
1069    }
1070
1071    #[test]
1072    fn test_predict_fof_new_data_finite() {
1073        let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1074        let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1075
1076        // Create slightly different new data
1077        let n_new = 10;
1078        let mx = 40;
1079        let mut new_x = FdMatrix::zeros(n_new, mx);
1080        for i in 0..n_new {
1081            let p = (i as f64 + 0.5) * PI / n_new as f64;
1082            for j in 0..mx {
1083                new_x[(i, j)] = (2.0 * PI * tx[j] + p).cos();
1084            }
1085        }
1086
1087        let predicted = predict_fof(&fit, &new_x).unwrap();
1088        assert_eq!(predicted.shape(), (n_new, 25));
1089        for i in 0..n_new {
1090            for j in 0..25 {
1091                assert!(
1092                    predicted[(i, j)].is_finite(),
1093                    "prediction should be finite at ({i}, {j})"
1094                );
1095            }
1096        }
1097    }
1098
1099    #[test]
1100    fn test_fof_regression_mismatched_n() {
1101        let (x, _y, tx, ty) = make_fof_data(30, 40, 25, 42);
1102        let y_bad = FdMatrix::zeros(20, 25);
1103        let result = fof_regression(&x, &y_bad, &tx, &ty, 3, 3);
1104        assert!(result.is_err());
1105    }
1106
1107    #[test]
1108    fn test_fof_regression_bad_argvals() {
1109        let (x, y, _tx, ty) = make_fof_data(30, 40, 25, 42);
1110        let bad_tx: Vec<f64> = (0..10).map(|j| j as f64).collect(); // wrong length
1111        let result = fof_regression(&x, &y, &bad_tx, &ty, 3, 3);
1112        assert!(result.is_err());
1113    }
1114
1115    #[test]
1116    fn test_fof_regression_zero_ncomp() {
1117        let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1118        assert!(fof_regression(&x, &y, &tx, &ty, 0, 3).is_err());
1119        assert!(fof_regression(&x, &y, &tx, &ty, 3, 0).is_err());
1120    }
1121
1122    #[test]
1123    fn test_fof_cv() {
1124        let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1125        let cv = fof_cv(&x, &y, &tx, &ty, 4, 4, 5, 42).unwrap();
1126        assert!(!cv.candidates.is_empty());
1127        assert!(cv.optimal.0 >= 1);
1128        assert!(cv.optimal.1 >= 1);
1129        assert!(cv.min_cv_mse.is_finite());
1130    }
1131
1132    #[test]
1133    fn test_fof_regression_residuals_consistent() {
1134        let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1135        let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1136
1137        let (n, my) = y.shape();
1138        for i in 0..n {
1139            for j in 0..my {
1140                let expected_resid = y[(i, j)] - fit.fitted[(i, j)];
1141                assert!(
1142                    (fit.residuals[(i, j)] - expected_resid).abs() < 1e-10,
1143                    "residual mismatch at ({i}, {j})"
1144                );
1145            }
1146        }
1147    }
1148
1149    // -----------------------------------------------------------------------
1150    // Helper: generate FoF data with subject IDs for RE tests
1151    // -----------------------------------------------------------------------
1152
1153    /// Generate test data with a grouped structure (repeated visits per subject).
1154    ///
1155    /// Each subject contributes `n_visits` curves.  Subject-level random shifts
1156    /// are added to Y so that random effects should be detectable.
1157    fn make_fof_re_data(
1158        n_subjects: usize,
1159        n_visits: usize,
1160        mx: usize,
1161        my: usize,
1162        seed: u64,
1163    ) -> (FdMatrix, FdMatrix, Vec<usize>, Vec<f64>, Vec<f64>) {
1164        let n = n_subjects * n_visits;
1165        let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1).max(1) as f64).collect();
1166        let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1).max(1) as f64).collect();
1167
1168        let mut x = FdMatrix::zeros(n, mx);
1169        let mut y = FdMatrix::zeros(n, my);
1170        let mut subject_ids = Vec::with_capacity(n);
1171
1172        for s in 0..n_subjects {
1173            // Subject-level random shift to Y (so RE should be non-zero)
1174            let shift =
1175                ((seed.wrapping_mul(13).wrapping_add(s as u64 * 97) % 1000) as f64 / 500.0) - 1.0;
1176
1177            for v in 0..n_visits {
1178                let i = s * n_visits + v;
1179                subject_ids.push(s);
1180
1181                let a = ((seed.wrapping_mul(17).wrapping_add(i as u64 * 31) % 1000) as f64 / 500.0)
1182                    - 1.0;
1183                let b = ((seed.wrapping_mul(7).wrapping_add(i as u64 * 53) % 1000) as f64 / 500.0)
1184                    - 1.0;
1185
1186                for j in 0..mx {
1187                    x[(i, j)] = a * (2.0 * PI * tx[j]).sin() + b * (4.0 * PI * tx[j]).cos();
1188                }
1189                for j in 0..my {
1190                    // Fixed effect: depends on x-loadings
1191                    // Random shift: subject-level
1192                    y[(i, j)] = 1.5 * a * (2.0 * PI * ty[j]).cos()
1193                        - 0.8 * b * (3.0 * PI * ty[j]).sin()
1194                        + shift  // subject-level random intercept in curve-space
1195                        + 0.01 * (seed.wrapping_add(i as u64 + j as u64) % 10) as f64;
1196                }
1197            }
1198        }
1199
1200        (x, y, subject_ids, tx, ty)
1201    }
1202
1203    // -----------------------------------------------------------------------
1204    // Task 1: fof_re_regression tests
1205    // -----------------------------------------------------------------------
1206
1207    #[test]
1208    fn test_fof_re_regression_dims() {
1209        let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1210        let n = x.nrows();
1211        let mx = x.ncols();
1212        let my = y.ncols();
1213        let n_subjects = 5;
1214        let config = FofReConfig {
1215            ncomp_x: 3,
1216            ncomp_y: 3,
1217            ..FofReConfig::default()
1218        };
1219        let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1220
1221        assert_eq!(fit.beta_surface.shape(), (my, mx), "beta_surface shape");
1222        assert_eq!(fit.fitted.shape(), (n, my), "fitted shape");
1223        assert_eq!(fit.residuals.shape(), (n, my), "residuals shape");
1224        assert_eq!(
1225            fit.random_effects.nrows(),
1226            n_subjects,
1227            "random_effects rows"
1228        );
1229        assert_eq!(fit.random_effects.ncols(), my, "random_effects cols");
1230        assert_eq!(fit.coef_matrix.shape(), (3, 3), "coef_matrix shape");
1231        assert_eq!(fit.intercept.len(), my, "intercept length");
1232        assert_eq!(fit.sigma2_u.len(), 3, "sigma2_u length");
1233        assert_eq!(fit.n_subjects, n_subjects, "n_subjects");
1234        assert_eq!(fit.ncomp_x, 3, "ncomp_x");
1235        assert_eq!(fit.ncomp_y, 3, "ncomp_y");
1236    }
1237
1238    #[test]
1239    fn test_fof_re_regression_invariant() {
1240        let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1241        let config = FofReConfig::default();
1242        let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1243
1244        let (n, my) = y.shape();
1245        for i in 0..n {
1246            for j in 0..my {
1247                let reconstructed = fit.fitted[(i, j)] + fit.residuals[(i, j)];
1248                assert!(
1249                    (reconstructed - y[(i, j)]).abs() < 1e-6,
1250                    "fitted+residuals != y at ({i},{j}): reconstructed={reconstructed}, y={}",
1251                    y[(i, j)]
1252                );
1253            }
1254        }
1255    }
1256
1257    #[test]
1258    fn test_fof_re_regression_re_nonzero() {
1259        // With distinct subject-level shifts, random_effects L2 norm must be > 0
1260        let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1261        let config = FofReConfig::default();
1262        let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1263
1264        let n_subjects = fit.random_effects.nrows();
1265        let my = fit.random_effects.ncols();
1266        let mut l2_sq = 0.0_f64;
1267        for s in 0..n_subjects {
1268            for j in 0..my {
1269                l2_sq += fit.random_effects[(s, j)].powi(2);
1270            }
1271        }
1272        let l2 = l2_sq.sqrt();
1273        assert!(
1274            l2 > 0.0,
1275            "random_effects L2 norm should be > 0 for grouped data, got {l2}"
1276        );
1277    }
1278
1279    #[test]
1280    fn test_fof_re_regression_ids_mismatch() {
1281        let (x, y, _ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1282        let bad_ids: Vec<usize> = vec![0, 1, 2]; // wrong length
1283        let config = FofReConfig::default();
1284        let err = fof_re_regression(&x, &y, &bad_ids, &tx, &ty, &config).unwrap_err();
1285        match err {
1286            FdarError::InvalidDimension { parameter, .. } => {
1287                assert_eq!(parameter, "subject_ids");
1288            }
1289            other => panic!("Expected InvalidDimension for subject_ids, got {other:?}"),
1290        }
1291    }
1292
1293    #[test]
1294    fn test_fof_re_reexport() {
1295        // Smoke test: fof_re_regression is accessible in scope via super::*
1296        let _ = fof_re_regression;
1297    }
1298
1299    // -----------------------------------------------------------------------
1300    // Task 2: predict_fof_re tests
1301    // -----------------------------------------------------------------------
1302
1303    #[test]
1304    fn test_predict_fof_re_shape() {
1305        let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1306        let my = y.ncols();
1307        let config = FofReConfig::default();
1308        let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1309
1310        let n_new = 7;
1311        let mx = x.ncols();
1312        let new_x = FdMatrix::from_column_major(
1313            (0..n_new * mx)
1314                .map(|k| {
1315                    let i = (k % n_new) as f64;
1316                    let j = (k / n_new) as f64;
1317                    (i * j * 0.1).sin()
1318                })
1319                .collect(),
1320            n_new,
1321            mx,
1322        )
1323        .unwrap();
1324
1325        let predicted = predict_fof_re(&fit, &new_x).unwrap();
1326        assert_eq!(predicted.shape(), (n_new, my), "shape mismatch");
1327
1328        // All entries must be finite
1329        for i in 0..n_new {
1330            for j in 0..my {
1331                assert!(
1332                    predicted[(i, j)].is_finite(),
1333                    "non-finite prediction at ({i},{j})"
1334                );
1335            }
1336        }
1337    }
1338
1339    #[test]
1340    fn test_predict_fof_re_training_matches_fixed() {
1341        // On training data, predict_fof_re returns fixed-effect-only fitted values.
1342        // These should equal: mean_y + sum_l (x_scores[i,l] * coef_matrix[:,l]) * phi_y^l
1343        // i.e., fitted minus the random-effect contribution.
1344        let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1345        let my = y.ncols();
1346        let config = FofReConfig::default();
1347        let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1348
1349        let fixed_only = predict_fof_re(&fit, &x).unwrap();
1350        assert_eq!(fixed_only.shape(), (x.nrows(), my));
1351
1352        // Compute expected fixed-effect part manually: mean_y + X_scores * coef_matrix reconstructed
1353        let x_scores = fit.fpca_x.project(&x).unwrap();
1354        let ncomp_x = fit.ncomp_x;
1355        let ncomp_y = fit.ncomp_y;
1356        let n = x.nrows();
1357
1358        for i in 0..n {
1359            for j in 0..my {
1360                let mut expected = fit.fpca_y.mean[j];
1361                for l in 0..ncomp_y {
1362                    let mut sc = 0.0;
1363                    for k in 0..ncomp_x {
1364                        sc += x_scores[(i, k)] * fit.coef_matrix[(k, l)];
1365                    }
1366                    expected += sc * fit.fpca_y.rotation[(j, l)];
1367                }
1368                assert!(
1369                    (fixed_only[(i, j)] - expected).abs() < 1e-6,
1370                    "fixed-only prediction mismatch at ({i},{j}): got {}, expected {expected}",
1371                    fixed_only[(i, j)]
1372                );
1373            }
1374        }
1375    }
1376}