Skip to main content

fdars_core/scalar_on_function/
additive.rs

1//! Nonparametric additive scalar-on-function regression.
2//!
3//! Implements six additive estimators for the model
4//! `E[Y | X] = μ + Σ_k f_k(ξ_k)` and its functional-distance variant,
5//! plus variable selection, permutation testing, and history-index estimation:
6//!
7//! - [`fam`] — Functional Additive Model (Müller & Yao 2008): one-pass NW over
8//!   FPC scores (no backfitting loop needed because FPC scores are uncorrelated).
9//! - [`fregre_gkam`] — Generalized Kernel Additive Model: iterative backfitting
10//!   over Nadaraya-Watson smoothers on functional L2 distances.
11//! - [`fregre_gsam`] — Generalized Spectral Additive Model: FPC-score basis
12//!   with additive NW smoothing; numerically equivalent to FAM under the
13//!   Gaussian identity link.
14//! - [`variable_selection`] — Group-penalized coordinate descent in FPC-score
15//!   space. Implements GroupLasso; GroupMCP/GroupSCAD are documented as future
16//!   work.
17//! - [`permutation_test_fam`] — Seeded permutation significance test for FAM.
18//! - [`history_index`] — Lagged-predictor-window estimator via marginal-
19//!   integration Nadaraya-Watson over a discretised lag grid.
20//!
21//! # R Baseline Divergences
22//!
23//! - **FAM:** R's `fdapace::FAM` uses PACE for FPC estimation; fdars uses
24//!   `fdata_to_pc_1d` (nalgebra SVD with Simpson's weights). R selects
25//!   per-component bandwidths by GCV; fdars does the same via `optim_bandwidth`.
26//!   No backfitting loop is used in either implementation because FPC
27//!   uncorrelatedness (Müller & Yao 2008) makes one pass equivalent to
28//!   infinite-iteration backfitting.
29//! - **GKAM:** R's `fregre.gkam` constructs explicit n×n hat matrices H_k and
30//!   solves the composite H_Q = H_1 + … + H_q system. fdars implements the
31//!   equivalent iterative update by applying NW weights directly (O(n) per
32//!   prediction point, O(n²) per covariate per iteration), avoiding the full
33//!   n×n hat-matrix materialisation. Only the Gaussian identity link is
34//!   supported; logit/log links require IRLS wrapping (documented gap).
35//! - **GSAM:** R's `fregre.gsam` delegates to `mgcv::gam` penalised splines.
36//!   fdars uses Nadaraya-Watson smoothing on FPC score columns (same model
37//!   class, different smoother). For the Gaussian identity case the two
38//!   implementations are numerically equivalent in the limit of small bandwidth
39//!   / large n. Non-Gaussian links are a documented known gap.
40//! - **variable_selection:** R's `refund::fosr.vs` implements function-on-scalar
41//!   regression (functional response, scalar predictors). fdars implements
42//!   scalar-on-function variable selection (scalar response, functional
43//!   predictors). The group-penalty formulation is analogous but the regression
44//!   direction is opposite. GroupMCP and GroupSCAD are documented as future work;
45//!   only GroupLasso (convex) is implemented this phase.
46//! - **history_index:** R's `refund::pffr` with `ff(..., limits)` implements the
47//!   full function-on-function history model as a lower-triangular bivariate
48//!   spline. fdars implements the scalar-on-function reduction (scalar Y, history
49//!   index evaluated at T = `argvals.last()`) via NW smoothing over a discretised
50//!   lag grid — same model class, marginal-integration approximation rather than
51//!   bivariate spline.
52
53use super::nonparametric::{compute_pairwise_distances, gaussian_kernel, select_bandwidth_loo};
54use crate::error::FdarError;
55use crate::matrix::FdMatrix;
56use crate::regression::{fdata_to_pc_1d, FpcaResult};
57use crate::smoothing::{nadaraya_watson, optim_bandwidth, CvCriterion};
58
59// ---------------------------------------------------------------------------
60// Config types
61// ---------------------------------------------------------------------------
62
63/// Configuration for the Functional Additive Model ([`fam`]).
64#[derive(Debug, Clone, PartialEq)]
65#[non_exhaustive]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67pub struct FamConfig {
68    /// Number of FPC components to use. 0 = auto-select via GCV (default: 0).
69    pub ncomp: usize,
70    /// Per-component NW bandwidth. 0.0 = auto-select per component via GCV (default: 0.0).
71    pub bandwidth: f64,
72    /// Kernel type: "gaussian" | "epanechnikov" | "tricube" (default: "gaussian").
73    pub kernel: String,
74    /// Number of bandwidth-grid points for `optim_bandwidth` (default: 20).
75    pub n_grid_bandwidth: usize,
76}
77
78impl Default for FamConfig {
79    fn default() -> Self {
80        Self {
81            ncomp: 0,
82            bandwidth: 0.0,
83            kernel: "gaussian".to_string(),
84            n_grid_bandwidth: 20,
85        }
86    }
87}
88
89/// Configuration for the Generalized Kernel Additive Model ([`fregre_gkam`]).
90#[derive(Debug, Clone, PartialEq)]
91#[non_exhaustive]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub struct GkamConfig {
94    /// Per-covariate bandwidth. 0.0 = auto via LOO-CV (default: 0.0).
95    pub bandwidth: f64,
96    /// Kernel type (default: "gaussian").
97    pub kernel: String,
98    /// Maximum backfitting iterations (default: 50).
99    pub max_iter: usize,
100    /// Convergence threshold on max component-delta (default: 1e-6).
101    pub epsilon: f64,
102}
103
104impl Default for GkamConfig {
105    fn default() -> Self {
106        Self {
107            bandwidth: 0.0,
108            kernel: "gaussian".to_string(),
109            max_iter: 50,
110            epsilon: 1e-6,
111        }
112    }
113}
114
115/// Configuration for the Generalized Spectral Additive Model ([`fregre_gsam`]).
116#[derive(Debug, Clone, PartialEq)]
117#[non_exhaustive]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub struct GsamConfig {
120    /// Number of FPC components. 0 = auto-select via GCV (default: 0).
121    pub ncomp: usize,
122    /// Per-component bandwidth. 0.0 = auto per component (default: 0.0).
123    pub bandwidth: f64,
124    /// Kernel type (default: "gaussian").
125    pub kernel: String,
126    /// Bandwidth-grid size for `optim_bandwidth` (default: 20).
127    pub n_grid_bandwidth: usize,
128}
129
130impl Default for GsamConfig {
131    fn default() -> Self {
132        Self {
133            ncomp: 0,
134            bandwidth: 0.0,
135            kernel: "gaussian".to_string(),
136            n_grid_bandwidth: 20,
137        }
138    }
139}
140
141// ---------------------------------------------------------------------------
142// Result types
143// ---------------------------------------------------------------------------
144
145/// Result of [`fam`].
146#[derive(Debug, Clone, PartialEq)]
147#[non_exhaustive]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct FamResult {
150    /// Fitted values ŷ (length n).
151    pub fitted_values: Vec<f64>,
152    /// Residuals y − ŷ (length n).
153    pub residuals: Vec<f64>,
154    /// Component fits f_k(ξ_k) for each observation, outer index = component.
155    /// Length = `ncomp + scalar_covariates.ncols()` (when scalar covariates are provided,
156    /// indices 0..ncomp correspond to FPC components; subsequent entries to scalar covariates).
157    pub component_fits: Vec<Vec<f64>>,
158    /// Mean response μ_y (intercept of the additive model).
159    pub intercept: f64,
160    /// Per-component optimal bandwidth. Length = `ncomp + scalar_covariates.ncols()`.
161    /// Indices 0..ncomp correspond to FPC components; subsequent entries to scalar covariates.
162    pub bandwidths: Vec<f64>,
163    /// Number of FPC components used.
164    pub ncomp: usize,
165    /// R² statistic.
166    pub r_squared: f64,
167    /// Embedded FPCA result for projecting new data.
168    pub fpca: FpcaResult,
169}
170
171/// Result of [`fregre_gkam`].
172#[derive(Debug, Clone, PartialEq)]
173#[non_exhaustive]
174#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
175pub struct GkamResult {
176    /// Fitted values ŷ (length n).
177    pub fitted_values: Vec<f64>,
178    /// Residuals y − ŷ (length n).
179    pub residuals: Vec<f64>,
180    /// Component fits f_k per predictor (q × n), outer index = predictor.
181    pub component_fits: Vec<Vec<f64>>,
182    /// Mean response intercept.
183    pub intercept: f64,
184    /// Per-predictor bandwidth (length q).
185    pub bandwidths: Vec<f64>,
186    /// Number of backfitting iterations performed.
187    pub iterations: usize,
188    /// Whether the backfitting loop converged within `max_iter`.
189    pub converged: bool,
190    /// R² statistic.
191    pub r_squared: f64,
192}
193
194/// Result of [`fregre_gsam`].
195#[derive(Debug, Clone, PartialEq)]
196#[non_exhaustive]
197#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
198pub struct GsamResult {
199    /// Fitted values ŷ (length n).
200    pub fitted_values: Vec<f64>,
201    /// Residuals y − ŷ (length n).
202    pub residuals: Vec<f64>,
203    /// Component fits f_j(ξ_j) per component. Length = `ncomp + scalar_covariates.ncols()`
204    /// (when scalar covariates are provided, indices 0..ncomp correspond to FPC components;
205    /// subsequent entries to scalar covariates).
206    pub component_fits: Vec<Vec<f64>>,
207    /// Mean response intercept.
208    pub intercept: f64,
209    /// Per-component bandwidth. Length = `ncomp + scalar_covariates.ncols()`.
210    /// Indices 0..ncomp correspond to FPC components; subsequent entries to scalar covariates.
211    pub bandwidths: Vec<f64>,
212    /// Number of FPC components used.
213    pub ncomp: usize,
214    /// R² statistic.
215    pub r_squared: f64,
216    /// Embedded FPCA result for projecting new data.
217    pub fpca: FpcaResult,
218}
219
220// ---------------------------------------------------------------------------
221// Private shared helpers
222// ---------------------------------------------------------------------------
223
224/// Resolve ncomp: auto-select by forward-selection GCV if 0, else clamp to min(n,m).
225///
226/// When `ncomp == 0`, performs forward selection: for each candidate count j = 1..=cap,
227/// evaluates the GCV of a 1-D NW smooth on the j-th FPC score applied to the partial
228/// residual after accounting for components 1..(j-1). Selects the count j that yields
229/// the best incremental GCV improvement. This correctly interprets the count as "use
230/// the first j components" rather than the index of the single best component.
231///
232/// Returns `Err(InvalidParameter)` if the explicitly-requested ncomp exceeds min(n,m).
233fn resolve_ncomp_additive(
234    ncomp: usize,
235    n: usize,
236    m: usize,
237    data: &FdMatrix,
238    y: &[f64],
239    argvals: &[f64],
240    kernel: &str,
241    n_grid: usize,
242) -> Result<usize, FdarError> {
243    let max_ncomp = n.min(m);
244    if ncomp == 0 {
245        // Auto-select via forward selection: for each candidate count j, evaluate
246        // the GCV of the j-th component applied to the partial residual given
247        // components 1..(j-1) already fit. Cap at min(n, m, 10) for speed.
248        let cap = max_ncomp.clamp(1, 10);
249        let fpca_full = fdata_to_pc_1d(data, cap, argvals)?;
250        let mu_y = y.iter().sum::<f64>() / n as f64;
251        let mut best_ncomp = 1usize;
252        let mut best_gcv = f64::INFINITY;
253        // component_fits_acc[k] = fitted values of the k-th component (0-indexed)
254        let mut component_fits_acc: Vec<Vec<f64>> = Vec::with_capacity(cap);
255
256        for j in 0..cap {
257            let xi_j: Vec<f64> = (0..n).map(|i| fpca_full.scores[(i, j)]).collect();
258            // Partial residual: y - mu_y - sum of previously fitted components
259            let partial: Vec<f64> = (0..n)
260                .map(|i| {
261                    let prior_sum: f64 = component_fits_acc.iter().map(|cf| cf[i]).sum();
262                    y[i] - mu_y - prior_sum
263                })
264                .collect();
265            let bw_result =
266                optim_bandwidth(&xi_j, &partial, None, CvCriterion::Gcv, kernel, n_grid);
267            let gcv_j = bw_result.value;
268            // Fit this component using the selected bandwidth
269            let fit_j = nadaraya_watson(&xi_j, &partial, &xi_j, bw_result.h_opt, kernel)
270                .unwrap_or_else(|_| vec![0.0; n]);
271            component_fits_acc.push(fit_j);
272            if gcv_j < best_gcv {
273                best_gcv = gcv_j;
274                best_ncomp = j + 1; // j is 0-indexed; best_ncomp is the count
275            }
276        }
277        Ok(best_ncomp)
278    } else if ncomp > max_ncomp {
279        Err(FdarError::InvalidParameter {
280            parameter: "config.ncomp",
281            message: format!(
282                "ncomp ({ncomp}) exceeds min(n, m) = {max_ncomp}; reduce ncomp or provide more data"
283            ),
284        })
285    } else {
286        Ok(ncomp)
287    }
288}
289
290/// Core additive-smooth forward pass over FPC scores (shared by fam and fregre_gsam).
291///
292/// Fits `f_k(ξ_k)` for k = 0..ncomp via one sequential pass of NW smoothers on partial
293/// residuals. Because FPC scores are uncorrelated (Müller & Yao 2008), this single pass
294/// achieves the same result as infinite-iteration backfitting.
295///
296/// Returns `(component_fits, bandwidths, intercept, fitted_values, residuals, r_squared)`.
297#[allow(clippy::too_many_arguments)]
298fn fpc_additive_smooth(
299    fpca: &FpcaResult,
300    y: &[f64],
301    n: usize,
302    ncomp: usize,
303    bandwidth: f64,
304    kernel: &str,
305    n_grid: usize,
306    scalar_covariates: Option<&FdMatrix>,
307) -> Result<(Vec<Vec<f64>>, Vec<f64>, f64, Vec<f64>, Vec<f64>, f64), FdarError> {
308    let mu_y = y.iter().sum::<f64>() / n as f64;
309
310    // Count total components including scalar covariates
311    let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
312    let total_comp = ncomp + p_scalar;
313
314    // Collect all score columns: FPC scores first, then scalar covariates
315    let mut all_scores: Vec<Vec<f64>> = Vec::with_capacity(total_comp);
316    for k in 0..ncomp {
317        all_scores.push((0..n).map(|i| fpca.scores[(i, k)]).collect());
318    }
319    if let Some(sc) = scalar_covariates {
320        for j in 0..p_scalar {
321            all_scores.push((0..n).map(|i| sc[(i, j)]).collect());
322        }
323    }
324
325    // One forward pass: for each component, build partial residual and fit NW
326    let mut component_fits: Vec<Vec<f64>> = vec![vec![0.0; n]; total_comp];
327    let mut bandwidths = vec![0.0_f64; total_comp];
328
329    for k in 0..total_comp {
330        // Partial residual = y - mu_y - sum_{j != k} f_j
331        let partial: Vec<f64> = (0..n)
332            .map(|i| {
333                let others: f64 = (0..total_comp)
334                    .filter(|&j| j != k)
335                    .map(|j| component_fits[j][i])
336                    .sum();
337                y[i] - mu_y - others
338            })
339            .collect();
340
341        let xi_k = &all_scores[k];
342        let h = if bandwidth > 0.0 {
343            bandwidth
344        } else {
345            optim_bandwidth(xi_k, &partial, None, CvCriterion::Gcv, kernel, n_grid).h_opt
346        };
347        bandwidths[k] = h;
348
349        // nadaraya_watson returns Err only if bandwidth <= 0 or slices are empty; h > 0 always here.
350        component_fits[k] = nadaraya_watson(xi_k, &partial, xi_k, h, kernel)?;
351    }
352
353    // Assemble fitted values and residuals
354    let fitted_values: Vec<f64> = (0..n)
355        .map(|i| mu_y + (0..total_comp).map(|k| component_fits[k][i]).sum::<f64>())
356        .collect();
357    let residuals: Vec<f64> = y
358        .iter()
359        .zip(&fitted_values)
360        .map(|(&yi, &yh)| yi - yh)
361        .collect();
362
363    // R² via shared helper (p = total_comp for df counting)
364    let (r_squared, _) = super::compute_r_squared(y, &residuals, total_comp);
365
366    // Only return the ncomp FPC-score components (not scalar covariate components)
367    // plus the bandwidths split accordingly.
368    // But the contract says component_fits has length ncomp+p_scalar; callers can slice.
369    Ok((
370        component_fits,
371        bandwidths,
372        mu_y,
373        fitted_values,
374        residuals,
375        r_squared,
376    ))
377}
378
379// ---------------------------------------------------------------------------
380// Public estimators
381// ---------------------------------------------------------------------------
382
383/// Functional Additive Model (FAM) — Müller & Yao (2008).
384///
385/// Fits `E[Y | X] = μ_Y + Σ_{k=1}^{K} f_k(ξ_k)` where `ξ_k` are the k-th
386/// functional principal component scores of `X`. Because FPC scores are
387/// uncorrelated (orthogonal in L²), fitting each component reduces to an
388/// independent 1-D Nadaraya-Watson regression on the partial residual — a
389/// single sequential forward pass achieves the same result as infinite-iteration
390/// backfitting.
391///
392/// # Arguments
393/// * `data` — Functional predictor matrix (n × m, column-major).
394/// * `y` — Scalar response vector (length n).
395/// * `argvals` — Evaluation grid (length m).
396/// * `scalar_covariates` — Optional scalar covariates (n × p); treated as
397///   additional additive components in the same forward pass.
398/// * `config` — Tuning parameters; see [`FamConfig`].
399///
400/// # Errors
401/// Returns [`FdarError::InvalidDimension`] if:
402/// - `data` has 0 rows or 0 columns,
403/// - `y.len() != n`,
404/// - `argvals.len() != m`, or
405/// - `scalar_covariates.nrows() != n`.
406///
407/// Returns [`FdarError::InvalidParameter`] if an explicitly-provided
408/// `config.ncomp` exceeds `min(n, m)`.
409///
410/// # Examples
411///
412/// ```
413/// use fdars_core::matrix::FdMatrix;
414/// use fdars_core::fam;
415/// use fdars_core::scalar_on_function::FamConfig;
416///
417/// let n = 30;
418/// let m = 20;
419/// let data = FdMatrix::from_column_major(
420///     (0..n*m).map(|i| (i as f64 * 0.1).sin()).collect(),
421///     n, m,
422/// ).unwrap();
423/// let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
424/// let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).cos()).collect();
425/// let result = fam(&data, &y, &argvals, None, &FamConfig::default()).unwrap();
426/// assert_eq!(result.fitted_values.len(), n);
427/// assert!(result.r_squared >= 0.0);
428/// ```
429#[must_use = "expensive computation whose result should not be discarded"]
430pub fn fam(
431    data: &FdMatrix,
432    y: &[f64],
433    argvals: &[f64],
434    scalar_covariates: Option<&FdMatrix>,
435    config: &FamConfig,
436) -> Result<FamResult, FdarError> {
437    let (n, m) = data.shape();
438
439    // Validate inputs
440    if n == 0 {
441        return Err(FdarError::InvalidDimension {
442            parameter: "data",
443            expected: "at least 1 row".to_string(),
444            actual: "0".to_string(),
445        });
446    }
447    if m == 0 {
448        return Err(FdarError::InvalidDimension {
449            parameter: "data",
450            expected: "at least 1 column".to_string(),
451            actual: "0".to_string(),
452        });
453    }
454    if y.len() != n {
455        return Err(FdarError::InvalidDimension {
456            parameter: "y",
457            expected: format!("{n}"),
458            actual: format!("{}", y.len()),
459        });
460    }
461    if argvals.len() != m {
462        return Err(FdarError::InvalidDimension {
463            parameter: "argvals",
464            expected: format!("{m}"),
465            actual: format!("{}", argvals.len()),
466        });
467    }
468    if let Some(sc) = scalar_covariates {
469        if sc.nrows() != n {
470            return Err(FdarError::InvalidDimension {
471                parameter: "scalar_covariates",
472                expected: format!("{n} rows"),
473                actual: format!("{} rows", sc.nrows()),
474            });
475        }
476    }
477
478    // Resolve ncomp
479    let ncomp = resolve_ncomp_additive(
480        config.ncomp,
481        n,
482        m,
483        data,
484        y,
485        argvals,
486        &config.kernel,
487        config.n_grid_bandwidth,
488    )?;
489
490    // Compute FPC scores
491    let fpca = fdata_to_pc_1d(data, ncomp, argvals)?;
492
493    // One-pass additive smooth
494    let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
495    let total_comp = ncomp + p_scalar;
496    let (component_fits_all, bandwidths_all, intercept, fitted_values, residuals, r_squared) =
497        fpc_additive_smooth(
498            &fpca,
499            y,
500            n,
501            ncomp,
502            config.bandwidth,
503            &config.kernel,
504            config.n_grid_bandwidth,
505            scalar_covariates,
506        )?;
507
508    // Separate FPC component fits from scalar covariate fits
509    let component_fits: Vec<Vec<f64>> = component_fits_all.into_iter().take(total_comp).collect();
510    let bandwidths: Vec<f64> = bandwidths_all.into_iter().take(total_comp).collect();
511
512    Ok(FamResult {
513        fitted_values,
514        residuals,
515        component_fits,
516        intercept,
517        bandwidths,
518        ncomp,
519        r_squared,
520        fpca,
521    })
522}
523
524/// Generalized Kernel Additive Model (GKAM).
525///
526/// Fits `ŷ = μ + Σ_k f_k(X^k)` by iterative backfitting where each `f_k` is a
527/// Nadaraya-Watson smoother on the L2 distance kernel between functional curves.
528/// Unlike FAM, the predictor distances are not orthogonal, so true iterative
529/// backfitting is required for convergence.
530///
531/// # Arguments
532/// * `predictors` — Slice of functional predictor matrices (each n × m_k).
533/// * `y` — Scalar response (length n).
534/// * `argvals_list` — Evaluation grids; `argvals_list[k]` has length `predictors[k].ncols()`.
535/// * `scalar_covariates` — Optional scalar covariates (n × p); appended as extra additive terms.
536/// * `config` — Tuning parameters; see [`GkamConfig`].
537///
538/// # Errors
539/// Returns [`FdarError::InvalidDimension`] if:
540/// - `predictors` is empty,
541/// - `predictors.len() != argvals_list.len()`,
542/// - any `predictors[k].nrows() != y.len()`, or
543/// - any `argvals_list[k].len() != predictors[k].ncols()`.
544///
545/// # Examples
546///
547/// ```
548/// use fdars_core::matrix::FdMatrix;
549/// use fdars_core::fregre_gkam;
550/// use fdars_core::scalar_on_function::GkamConfig;
551///
552/// let n = 20;
553/// let m = 15;
554/// let data = FdMatrix::from_column_major(
555///     (0..n*m).map(|i| (i as f64 * 0.15).sin()).collect(),
556///     n, m,
557/// ).unwrap();
558/// let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
559/// let y: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
560/// let result = fregre_gkam(&[&data], &y, &[argvals.as_slice()], None, &GkamConfig::default()).unwrap();
561/// assert_eq!(result.fitted_values.len(), n);
562/// ```
563#[must_use = "expensive computation whose result should not be discarded"]
564pub fn fregre_gkam(
565    predictors: &[&FdMatrix],
566    y: &[f64],
567    argvals_list: &[&[f64]],
568    scalar_covariates: Option<&FdMatrix>,
569    config: &GkamConfig,
570) -> Result<GkamResult, FdarError> {
571    let n = y.len();
572
573    // Validate inputs
574    if n == 0 {
575        return Err(FdarError::InvalidDimension {
576            parameter: "y",
577            expected: "at least 1 observation".to_string(),
578            actual: "0".to_string(),
579        });
580    }
581    if predictors.is_empty() {
582        return Err(FdarError::InvalidDimension {
583            parameter: "predictors",
584            expected: "at least 1 functional predictor".to_string(),
585            actual: "0".to_string(),
586        });
587    }
588    if predictors.len() != argvals_list.len() {
589        return Err(FdarError::InvalidDimension {
590            parameter: "argvals_list",
591            expected: format!("{} (matching predictors.len())", predictors.len()),
592            actual: format!("{}", argvals_list.len()),
593        });
594    }
595    for (k, pred) in predictors.iter().enumerate() {
596        if pred.nrows() != n {
597            return Err(FdarError::InvalidDimension {
598                parameter: "predictors[k].nrows()",
599                expected: format!("{n} (y.len())"),
600                actual: format!("{} for predictor {k}", pred.nrows()),
601            });
602        }
603        if argvals_list[k].len() != pred.ncols() {
604            return Err(FdarError::InvalidDimension {
605                parameter: "argvals_list[k]",
606                expected: format!("{} (predictors[k].ncols())", pred.ncols()),
607                actual: format!("{} for predictor {k}", argvals_list[k].len()),
608            });
609        }
610    }
611    if let Some(sc) = scalar_covariates {
612        if sc.nrows() != n {
613            return Err(FdarError::InvalidDimension {
614                parameter: "scalar_covariates",
615                expected: format!("{n} rows"),
616                actual: format!("{} rows", sc.nrows()),
617            });
618        }
619    }
620
621    let q = predictors.len();
622    let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
623    let total_comp = q + p_scalar;
624
625    let mu_y = y.iter().sum::<f64>() / n as f64;
626
627    // Precompute pairwise L2 distance matrices (once per predictor)
628    let dist_matrices: Vec<Vec<f64>> = predictors
629        .iter()
630        .zip(argvals_list.iter())
631        .map(|(pred, argvals)| compute_pairwise_distances(pred, argvals))
632        .collect();
633
634    // Select per-covariate bandwidths
635    let bandwidths_func: Vec<f64> = if config.bandwidth > 0.0 {
636        vec![config.bandwidth; q]
637    } else {
638        dist_matrices
639            .iter()
640            .map(|dists| select_bandwidth_loo(dists, y, n, None))
641            .collect()
642    };
643
644    // For scalar covariates, compute Euclidean distances and bandwidths
645    let scalar_dists: Vec<Vec<f64>> = if let Some(sc) = scalar_covariates {
646        (0..p_scalar)
647            .map(|j| {
648                let mut d = vec![0.0_f64; n * n];
649                for i in 0..n {
650                    for jj in (i + 1)..n {
651                        let diff = sc[(i, j)] - sc[(jj, j)];
652                        let dist = diff.abs();
653                        d[i * n + jj] = dist;
654                        d[jj * n + i] = dist;
655                    }
656                }
657                d
658            })
659            .collect()
660    } else {
661        Vec::new()
662    };
663
664    let scalar_bandwidths: Vec<f64> = if p_scalar > 0 {
665        if config.bandwidth > 0.0 {
666            vec![config.bandwidth; p_scalar]
667        } else {
668            scalar_dists
669                .iter()
670                .map(|dists| select_bandwidth_loo(dists, y, n, None))
671                .collect()
672        }
673    } else {
674        Vec::new()
675    };
676
677    // Merge bandwidths: functional first, then scalar
678    let mut all_bandwidths = bandwidths_func.clone();
679    all_bandwidths.extend_from_slice(&scalar_bandwidths);
680
681    // Initialize component fits to zero
682    let mut component_fits = vec![vec![0.0_f64; n]; total_comp];
683    let mut converged = false;
684    let mut iterations = 0;
685
686    // Iterative backfitting loop (bounded by max_iter)
687    for iter in 0..config.max_iter {
688        let mut max_delta = 0.0_f64;
689
690        // Update functional predictor components
691        for k in 0..q {
692            let h_k = all_bandwidths[k];
693            let dists_k = &dist_matrices[k];
694
695            // Compute adjusted response: y - mu - sum_{j != k} f_j
696            let adjusted: Vec<f64> = (0..n)
697                .map(|i| {
698                    let others: f64 = (0..total_comp)
699                        .filter(|&j| j != k)
700                        .map(|j| component_fits[j][i])
701                        .sum();
702                    y[i] - mu_y - others
703                })
704                .collect();
705
706            // Apply NW smoother on L2 distance kernel (O(n) per point)
707            let new_fk: Vec<f64> = (0..n)
708                .map(|i| {
709                    let mut num = 0.0_f64;
710                    let mut den = 0.0_f64;
711                    for j in 0..n {
712                        let w = gaussian_kernel(dists_k[i * n + j], h_k);
713                        num += w * adjusted[j];
714                        den += w;
715                    }
716                    if den > 1e-15 {
717                        num / den
718                    } else {
719                        adjusted[i]
720                    }
721                })
722                .collect();
723
724            // Track max change across all observations
725            let delta = component_fits[k]
726                .iter()
727                .zip(&new_fk)
728                .map(|(old, &new)| (old - new).abs())
729                .fold(0.0_f64, f64::max);
730            max_delta = max_delta.max(delta);
731            component_fits[k] = new_fk;
732        }
733
734        // Update scalar covariate components
735        for s_idx in 0..p_scalar {
736            let k = q + s_idx;
737            let h_k = all_bandwidths[k];
738            let dists_k = &scalar_dists[s_idx];
739
740            let adjusted: Vec<f64> = (0..n)
741                .map(|i| {
742                    let others: f64 = (0..total_comp)
743                        .filter(|&j| j != k)
744                        .map(|j| component_fits[j][i])
745                        .sum();
746                    y[i] - mu_y - others
747                })
748                .collect();
749
750            let new_fk: Vec<f64> = (0..n)
751                .map(|i| {
752                    let mut num = 0.0_f64;
753                    let mut den = 0.0_f64;
754                    for j in 0..n {
755                        let w = gaussian_kernel(dists_k[i * n + j], h_k);
756                        num += w * adjusted[j];
757                        den += w;
758                    }
759                    if den > 1e-15 {
760                        num / den
761                    } else {
762                        adjusted[i]
763                    }
764                })
765                .collect();
766
767            let delta = component_fits[k]
768                .iter()
769                .zip(&new_fk)
770                .map(|(old, &new)| (old - new).abs())
771                .fold(0.0_f64, f64::max);
772            max_delta = max_delta.max(delta);
773            component_fits[k] = new_fk;
774        }
775
776        iterations = iter + 1;
777        if max_delta < config.epsilon {
778            converged = true;
779            break;
780        }
781    }
782
783    // Assemble result
784    let fitted_values: Vec<f64> = (0..n)
785        .map(|i| mu_y + (0..total_comp).map(|k| component_fits[k][i]).sum::<f64>())
786        .collect();
787    let residuals: Vec<f64> = y
788        .iter()
789        .zip(&fitted_values)
790        .map(|(&yi, &yh)| yi - yh)
791        .collect();
792    let (r_squared, _) = super::compute_r_squared(y, &residuals, total_comp);
793
794    Ok(GkamResult {
795        fitted_values,
796        residuals,
797        component_fits,
798        intercept: mu_y,
799        bandwidths: all_bandwidths,
800        iterations,
801        converged,
802        r_squared,
803    })
804}
805
806/// Generalized Spectral Additive Model (GSAM).
807///
808/// Fits the same FPC-score additive model as [`fam`] but is framed as a
809/// generalised additive model in the FPC score space. Under the Gaussian
810/// identity link the implementation is numerically equivalent to FAM.
811///
812/// # Arguments
813/// * `data` — Functional predictor matrix (n × m, column-major).
814/// * `y` — Scalar response (length n).
815/// * `argvals` — Evaluation grid (length m).
816/// * `scalar_covariates` — Optional scalar covariates (n × p).
817/// * `config` — Tuning parameters; see [`GsamConfig`].
818///
819/// # Errors
820/// Returns [`FdarError::InvalidDimension`] or [`FdarError::InvalidParameter`]
821/// (with `ncomp > min(n, m)`) under the same conditions as [`fam`].
822///
823/// # Examples
824///
825/// ```
826/// use fdars_core::matrix::FdMatrix;
827/// use fdars_core::fregre_gsam;
828/// use fdars_core::scalar_on_function::GsamConfig;
829///
830/// let n = 30;
831/// let m = 20;
832/// let data = FdMatrix::from_column_major(
833///     (0..n*m).map(|i| (i as f64 * 0.1).cos()).collect(),
834///     n, m,
835/// ).unwrap();
836/// let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
837/// let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.3).sin()).collect();
838/// let result = fregre_gsam(&data, &y, &argvals, None, &GsamConfig::default()).unwrap();
839/// assert_eq!(result.fitted_values.len(), n);
840/// ```
841#[must_use = "expensive computation whose result should not be discarded"]
842pub fn fregre_gsam(
843    data: &FdMatrix,
844    y: &[f64],
845    argvals: &[f64],
846    scalar_covariates: Option<&FdMatrix>,
847    config: &GsamConfig,
848) -> Result<GsamResult, FdarError> {
849    let (n, m) = data.shape();
850
851    // Validate inputs — identical to fam
852    if n == 0 {
853        return Err(FdarError::InvalidDimension {
854            parameter: "data",
855            expected: "at least 1 row".to_string(),
856            actual: "0".to_string(),
857        });
858    }
859    if m == 0 {
860        return Err(FdarError::InvalidDimension {
861            parameter: "data",
862            expected: "at least 1 column".to_string(),
863            actual: "0".to_string(),
864        });
865    }
866    if y.len() != n {
867        return Err(FdarError::InvalidDimension {
868            parameter: "y",
869            expected: format!("{n}"),
870            actual: format!("{}", y.len()),
871        });
872    }
873    if argvals.len() != m {
874        return Err(FdarError::InvalidDimension {
875            parameter: "argvals",
876            expected: format!("{m}"),
877            actual: format!("{}", argvals.len()),
878        });
879    }
880    if let Some(sc) = scalar_covariates {
881        if sc.nrows() != n {
882            return Err(FdarError::InvalidDimension {
883                parameter: "scalar_covariates",
884                expected: format!("{n} rows"),
885                actual: format!("{} rows", sc.nrows()),
886            });
887        }
888    }
889
890    // Resolve ncomp (same logic as fam, including InvalidParameter for ncomp > min(n,m))
891    let ncomp = resolve_ncomp_additive(
892        config.ncomp,
893        n,
894        m,
895        data,
896        y,
897        argvals,
898        &config.kernel,
899        config.n_grid_bandwidth,
900    )?;
901
902    // Compute FPC scores
903    let fpca = fdata_to_pc_1d(data, ncomp, argvals)?;
904
905    // One-pass additive smooth (identical path to fam — GSAM = FAM under Gaussian identity link)
906    let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
907    let total_comp = ncomp + p_scalar;
908    let (component_fits_all, bandwidths_all, intercept, fitted_values, residuals, r_squared) =
909        fpc_additive_smooth(
910            &fpca,
911            y,
912            n,
913            ncomp,
914            config.bandwidth,
915            &config.kernel,
916            config.n_grid_bandwidth,
917            scalar_covariates,
918        )?;
919
920    let component_fits: Vec<Vec<f64>> = component_fits_all.into_iter().take(total_comp).collect();
921    let bandwidths: Vec<f64> = bandwidths_all.into_iter().take(total_comp).collect();
922
923    Ok(GsamResult {
924        fitted_values,
925        residuals,
926        component_fits,
927        intercept,
928        bandwidths,
929        ncomp,
930        r_squared,
931        fpca,
932    })
933}
934
935// ---------------------------------------------------------------------------
936// Wave-2 config and result types
937// ---------------------------------------------------------------------------
938
939/// Penalty type for [`variable_selection`].
940///
941/// Only `GroupLasso` is fully implemented. `GroupMcp` and `GroupScad` are
942/// documented here for API completeness; calling `variable_selection` with
943/// either returns `FdarError::InvalidParameter` — they are deferred to a
944/// future phase.
945#[derive(Debug, Clone, Copy, PartialEq)]
946#[non_exhaustive]
947#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
948pub enum VarSelectPenalty {
949    /// Group lasso (convex); fully implemented. Recommended default.
950    GroupLasso,
951    /// Group MCP (minimax concave penalty). **Not yet implemented** — returns
952    /// `FdarError::InvalidParameter`; deferred to a future phase.
953    GroupMcp,
954    /// Group SCAD (smoothly clipped absolute deviation). **Not yet
955    /// implemented** — returns `FdarError::InvalidParameter`; deferred to a
956    /// future phase.
957    GroupScad,
958    /// Ordinary least squares (no group penalty). Sets all predictors active.
959    Ls,
960}
961
962/// Configuration for [`variable_selection`].
963#[derive(Debug, Clone, PartialEq)]
964#[non_exhaustive]
965#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
966pub struct VarSelectConfig {
967    /// FPC components per predictor. 0 = auto-select via GCV (default: 3).
968    pub ncomp: usize,
969    /// Group penalty type (default: [`VarSelectPenalty::GroupLasso`]).
970    pub penalty: VarSelectPenalty,
971    /// Penalty weight λ. 0.0 = CV-select over a grid (default: 0.0).
972    pub lambda: f64,
973    /// Maximum coordinate-descent iterations (default: 100).
974    pub max_iter: usize,
975    /// Convergence threshold on max coefficient delta (default: 1e-5).
976    pub epsilon: f64,
977    /// Grid size for λ selection (default: 20).
978    pub lambda_n_grid: usize,
979}
980
981impl Default for VarSelectConfig {
982    fn default() -> Self {
983        Self {
984            ncomp: 3,
985            penalty: VarSelectPenalty::GroupLasso,
986            lambda: 0.0,
987            max_iter: 100,
988            epsilon: 1e-5,
989            lambda_n_grid: 20,
990        }
991    }
992}
993
994/// Result of [`variable_selection`].
995#[derive(Debug, Clone, PartialEq)]
996#[non_exhaustive]
997#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
998pub struct VarSelectResult {
999    /// Whether each functional predictor is active (length P).
1000    pub active_predictors: Vec<bool>,
1001    /// Group-lasso coefficient vector per predictor (P × K_p).
1002    pub coefficients: Vec<Vec<f64>>,
1003    /// Fitted values ŷ (length n).
1004    pub fitted_values: Vec<f64>,
1005    /// Residuals y − ŷ (length n).
1006    pub residuals: Vec<f64>,
1007    /// Intercept (mean response).
1008    pub intercept: f64,
1009    /// Selected or provided λ.
1010    pub lambda: f64,
1011    /// R² statistic.
1012    pub r_squared: f64,
1013    /// Coordinate-descent iterations performed.
1014    pub iterations: usize,
1015    /// Whether the coordinate-descent loop converged.
1016    pub converged: bool,
1017    /// FPCA result for each predictor (for projecting new data).
1018    pub fpcas: Vec<FpcaResult>,
1019}
1020
1021/// Test statistic for [`permutation_test_fam`].
1022#[derive(Debug, Clone, Copy, PartialEq)]
1023#[non_exhaustive]
1024#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1025pub enum PermTestStatistic {
1026    /// R² of the full additive fit (default).
1027    R2,
1028    /// L2 norm of fitted values.
1029    FittedNorm,
1030    /// Sum of integrated component norms (FAM only).
1031    ComponentNorm,
1032}
1033
1034/// Configuration for [`permutation_test_fam`].
1035#[derive(Debug, Clone, PartialEq)]
1036#[non_exhaustive]
1037#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1038pub struct PermTestConfig {
1039    /// Number of permutations (default: 999).
1040    pub n_perm: usize,
1041    /// Random seed for reproducibility (default: 42).
1042    pub seed: u64,
1043    /// Test statistic to use (default: [`PermTestStatistic::R2`]).
1044    pub statistic: PermTestStatistic,
1045}
1046
1047impl Default for PermTestConfig {
1048    fn default() -> Self {
1049        Self {
1050            n_perm: 999,
1051            seed: 42,
1052            statistic: PermTestStatistic::R2,
1053        }
1054    }
1055}
1056
1057/// Result of [`permutation_test_fam`].
1058#[derive(Debug, Clone, PartialEq)]
1059#[non_exhaustive]
1060pub struct PermTestResult {
1061    /// Permutation p-value: (n_ge + 1) / (n_perm_success + 1).
1062    /// Uses the count of successful refits in the denominator so that failed
1063    /// permutations (e.g., degenerate shuffled data) do not bias the p-value.
1064    pub p_value: f64,
1065    /// Test statistic on the original (unpermuted) data.
1066    pub observed_statistic: f64,
1067    /// Test statistic for each permuted dataset (length ≤ n_perm).
1068    pub null_statistics: Vec<f64>,
1069    /// Number of permutation refits that returned `Ok`.
1070    pub n_perm_success: usize,
1071}
1072
1073/// Configuration for [`history_index`].
1074#[derive(Debug, Clone, PartialEq)]
1075#[non_exhaustive]
1076#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1077pub struct HistoryIndexConfig {
1078    /// Lag window length Δ; must be ≤ `argvals` range.
1079    pub window: f64,
1080    /// Number of lag grid points (default: 20).
1081    pub n_lags: usize,
1082    /// Bandwidth for the history weight function. 0.0 = auto via GCV (default: 0.0).
1083    pub bandwidth: f64,
1084    /// Kernel type (default: "gaussian").
1085    pub kernel: String,
1086}
1087
1088impl Default for HistoryIndexConfig {
1089    fn default() -> Self {
1090        Self {
1091            window: 1.0,
1092            n_lags: 20,
1093            bandwidth: 0.0,
1094            kernel: "gaussian".to_string(),
1095        }
1096    }
1097}
1098
1099/// Result of [`history_index`].
1100#[derive(Debug, Clone, PartialEq)]
1101#[non_exhaustive]
1102#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1103pub struct HistoryIndexResult {
1104    /// Fitted values ŷ (length n).
1105    pub fitted_values: Vec<f64>,
1106    /// Residuals y − ŷ (length n).
1107    pub residuals: Vec<f64>,
1108    /// Intercept β₀.
1109    pub intercept: f64,
1110    /// Slope β₁ on the history score.
1111    pub slope: f64,
1112    /// Estimated history weight function γ (length n_lags).
1113    pub gamma: Vec<f64>,
1114    /// Lag discretisation points (length n_lags).
1115    pub lag_grid: Vec<f64>,
1116    /// Σ_l γ_l · X_i(T−u_l) · Δu for each observation (length n).
1117    pub history_scores: Vec<f64>,
1118    /// R² statistic.
1119    pub r_squared: f64,
1120}
1121
1122// ---------------------------------------------------------------------------
1123// Wave-2 public estimators
1124// ---------------------------------------------------------------------------
1125
1126/// Variable selection for scalar-on-function regression via group-penalised
1127/// coordinate descent in FPC-score space (GroupLasso).
1128///
1129/// Each functional predictor `predictors[p]` is reduced to `K_p` FPC scores
1130/// (one group). Group-lasso coordinate descent then selects which groups are
1131/// active.
1132///
1133/// # Algorithm
1134///
1135/// 1. Run `fdata_to_pc_1d` on each predictor → score groups ξ^0, …, ξ^{P−1}.
1136/// 2. Build design X = \[μ | ξ^0 | … | ξ^{P−1} | Z\] (Z = optional scalar
1137///    covariates).
1138/// 3. If `config.lambda == 0.0`, 5-fold CV-select λ over a geometric grid from
1139///    `0.01·λ_max` to `λ_max` where `λ_max = max_g ||X_g'y|| / √K_g`.
1140///    Each fold trains on 4/5 of the data and evaluates held-out prediction error.
1141/// 4. Coordinate-descent group-lasso: for each group g compute the partial-
1142///    residual OLS update β̂_g via `cholesky_solve`, then soft-threshold:
1143///    `β_g = β̂_g · max(0, 1 − λ√K_g / ||β̂_g||)`.
1144/// 5. Iterate until `max(|Δβ|) < epsilon` or `max_iter` sweeps.
1145///
1146/// # R Baseline Divergence
1147///
1148/// R's `refund::fosr.vs` is a **function-on-scalar** model (functional response,
1149/// scalar predictors). fdars implements **scalar-on-function** variable selection
1150/// (scalar response, functional predictors). The group-penalty formulation is
1151/// analogous but the regression direction is opposite. GroupMCP and GroupSCAD are
1152/// documented as future work; only GroupLasso is implemented this phase.
1153///
1154/// # Errors
1155///
1156/// Returns [`FdarError::InvalidParameter`] for unsupported penalty variants
1157/// (`GroupMcp`, `GroupScad`).
1158///
1159/// Returns [`FdarError::InvalidDimension`] if:
1160/// - `predictors` is empty,
1161/// - `predictors.len() != argvals_list.len()`, or
1162/// - any `predictors[p].nrows() != y.len()`.
1163///
1164/// Returns [`FdarError::ComputationFailed`] if the OLS sub-step encounters a
1165/// singular group design matrix.
1166///
1167/// # Examples
1168///
1169/// ```
1170/// use fdars_core::matrix::FdMatrix;
1171/// use fdars_core::variable_selection;
1172/// use fdars_core::scalar_on_function::{VarSelectConfig, VarSelectPenalty};
1173///
1174/// let n = 20;
1175/// let m = 10;
1176/// let data = FdMatrix::from_column_major(
1177///     (0..n*m).map(|i| (i as f64 * 0.1).sin()).collect(),
1178///     n, m,
1179/// ).unwrap();
1180/// let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1181/// let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).cos()).collect();
1182/// let mut config = VarSelectConfig::default();
1183/// config.ncomp = 2;
1184/// let result = variable_selection(&[&data], &y, &[argvals.as_slice()], None, &config).unwrap();
1185/// assert_eq!(result.active_predictors.len(), 1);
1186/// ```
1187#[must_use = "expensive computation whose result should not be discarded"]
1188pub fn variable_selection(
1189    predictors: &[&FdMatrix],
1190    y: &[f64],
1191    argvals_list: &[&[f64]],
1192    scalar_covariates: Option<&FdMatrix>,
1193    config: &VarSelectConfig,
1194) -> Result<VarSelectResult, FdarError> {
1195    // Check for unsupported penalty variants first
1196    match config.penalty {
1197        VarSelectPenalty::GroupMcp | VarSelectPenalty::GroupScad => {
1198            return Err(FdarError::InvalidParameter {
1199                parameter: "config.penalty",
1200                message: "GroupMcp and GroupScad are not yet implemented; use GroupLasso"
1201                    .to_string(),
1202            });
1203        }
1204        VarSelectPenalty::GroupLasso | VarSelectPenalty::Ls => {}
1205    }
1206
1207    let n = y.len();
1208
1209    // Validate inputs
1210    if predictors.is_empty() {
1211        return Err(FdarError::InvalidDimension {
1212            parameter: "predictors",
1213            expected: "at least 1 functional predictor".to_string(),
1214            actual: "0".to_string(),
1215        });
1216    }
1217    if predictors.len() != argvals_list.len() {
1218        return Err(FdarError::InvalidDimension {
1219            parameter: "argvals_list",
1220            expected: format!("{} (matching predictors.len())", predictors.len()),
1221            actual: format!("{}", argvals_list.len()),
1222        });
1223    }
1224    for (p, pred) in predictors.iter().enumerate() {
1225        if pred.nrows() != n {
1226            return Err(FdarError::InvalidDimension {
1227                parameter: "predictors[p].nrows()",
1228                expected: format!("{n} (y.len())"),
1229                actual: format!("{} for predictor {p}", pred.nrows()),
1230            });
1231        }
1232    }
1233
1234    let big_p = predictors.len();
1235    let mu_y = y.iter().sum::<f64>() / n as f64;
1236
1237    // Compute FPC scores for each predictor
1238    let ncomp_per = if config.ncomp == 0 { 3 } else { config.ncomp };
1239
1240    let mut fpcas: Vec<FpcaResult> = Vec::with_capacity(big_p);
1241    let mut score_groups: Vec<Vec<Vec<f64>>> = Vec::with_capacity(big_p); // [p][k][i]
1242
1243    for p in 0..big_p {
1244        let pred = predictors[p];
1245        let argvals = argvals_list[p];
1246        let (np, mp) = pred.shape();
1247        let k_p = ncomp_per.min(np.min(mp).saturating_sub(1).max(1));
1248        let fpca_p = fdata_to_pc_1d(pred, k_p, argvals)?;
1249        let k_actual = fpca_p.scores.ncols();
1250        let group_scores: Vec<Vec<f64>> = (0..k_actual)
1251            .map(|k| (0..n).map(|i| fpca_p.scores[(i, k)]).collect())
1252            .collect();
1253        score_groups.push(group_scores);
1254        fpcas.push(fpca_p);
1255    }
1256
1257    // Handle Ls (ordinary least squares, no penalty)
1258    if config.penalty == VarSelectPenalty::Ls {
1259        return variable_selection_ls(y, n, mu_y, big_p, fpcas, score_groups, scalar_covariates);
1260    }
1261
1262    // Build flat design matrix columns per group (excluding intercept here)
1263    // group_starts[p] = column index of group p in the flat score matrix
1264    let k_sizes: Vec<usize> = score_groups.iter().map(|g| g.len()).collect();
1265
1266    // Compute lambda_max = max_g || X_g' (y - mu_y) || / sqrt(K_g)
1267    let y_centered: Vec<f64> = y.iter().map(|&yi| yi - mu_y).collect();
1268    let lambda_max = k_sizes
1269        .iter()
1270        .zip(score_groups.iter())
1271        .map(|(&k_g, group)| {
1272            let norm_sq: f64 = group
1273                .iter()
1274                .map(|col| {
1275                    let xgty: f64 = col.iter().zip(&y_centered).map(|(&x, &yc)| x * yc).sum();
1276                    xgty * xgty
1277                })
1278                .sum::<f64>();
1279            norm_sq.sqrt() / (k_g as f64).sqrt()
1280        })
1281        .fold(0.0_f64, f64::max)
1282        .max(1e-10); // avoid zero lambda_max
1283
1284    // Select lambda via LOO-CV on a grid if config.lambda == 0.0
1285    let lambda = if config.lambda > 0.0 {
1286        config.lambda
1287    } else {
1288        select_group_lasso_lambda(
1289            y,
1290            &y_centered,
1291            mu_y,
1292            n,
1293            &score_groups,
1294            &k_sizes,
1295            lambda_max,
1296            config.lambda_n_grid,
1297            config.max_iter,
1298            config.epsilon,
1299            scalar_covariates,
1300        )
1301    };
1302
1303    // Run group lasso coordinate descent at selected lambda
1304    let (coefficients, iterations, converged) = group_lasso_cd(
1305        y,
1306        &y_centered,
1307        mu_y,
1308        n,
1309        &score_groups,
1310        &k_sizes,
1311        lambda,
1312        config.max_iter,
1313        config.epsilon,
1314        scalar_covariates,
1315    )?;
1316
1317    // Compute fitted values and residuals
1318    let fitted_values: Vec<f64> = compute_varselect_fitted(
1319        n,
1320        mu_y,
1321        &score_groups,
1322        &coefficients,
1323        scalar_covariates,
1324        big_p,
1325    );
1326    let residuals: Vec<f64> = y
1327        .iter()
1328        .zip(&fitted_values)
1329        .map(|(&yi, &yh)| yi - yh)
1330        .collect();
1331    let (r_squared, _) = super::compute_r_squared(y, &residuals, k_sizes.iter().sum::<usize>());
1332
1333    let active_predictors: Vec<bool> = coefficients[..big_p]
1334        .iter()
1335        .map(|beta_g| {
1336            let norm: f64 = beta_g.iter().map(|&b| b * b).sum::<f64>();
1337            norm.sqrt() > config.epsilon
1338        })
1339        .collect();
1340
1341    Ok(VarSelectResult {
1342        active_predictors,
1343        coefficients,
1344        fitted_values,
1345        residuals,
1346        intercept: mu_y,
1347        lambda,
1348        r_squared,
1349        iterations,
1350        converged,
1351        fpcas,
1352    })
1353}
1354
1355/// OLS path for `VarSelectPenalty::Ls` (no group penalty).
1356fn variable_selection_ls(
1357    y: &[f64],
1358    n: usize,
1359    mu_y: f64,
1360    big_p: usize,
1361    fpcas: Vec<FpcaResult>,
1362    score_groups: Vec<Vec<Vec<f64>>>,
1363    scalar_covariates: Option<&FdMatrix>,
1364) -> Result<VarSelectResult, FdarError> {
1365    let k_sizes: Vec<usize> = score_groups.iter().map(|g| g.len()).collect();
1366    let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
1367    let total_cols = k_sizes.iter().sum::<usize>() + p_scalar;
1368    // Build n×total_cols design (no intercept column — absorbed into mu_y)
1369    let mut x_flat = vec![0.0_f64; n * total_cols];
1370    let mut col_offset = 0;
1371    for grp in &score_groups {
1372        for col in grp {
1373            for (i, &v) in col.iter().enumerate() {
1374                x_flat[col_offset * n + i] = v;
1375            }
1376            col_offset += 1;
1377        }
1378    }
1379    if let Some(sc) = scalar_covariates {
1380        for j in 0..p_scalar {
1381            for i in 0..n {
1382                x_flat[col_offset * n + i] = sc[(i, j)];
1383            }
1384            col_offset += 1;
1385        }
1386    }
1387    let x_mat = FdMatrix::from_column_major(x_flat, n, total_cols).map_err(|e| {
1388        FdarError::ComputationFailed {
1389            operation: "variable_selection_ls design matrix",
1390            detail: e.to_string(),
1391        }
1392    })?;
1393    let y_centered: Vec<f64> = y.iter().map(|&yi| yi - mu_y).collect();
1394    let xtx = super::compute_xtx(&x_mat);
1395    let xty: Vec<f64> = (0..total_cols)
1396        .map(|k| {
1397            x_mat
1398                .column(k)
1399                .iter()
1400                .zip(&y_centered)
1401                .map(|(&xv, &yv)| xv * yv)
1402                .sum::<f64>()
1403        })
1404        .collect();
1405    let l = super::cholesky_factor(&xtx, total_cols).map_err(|_| FdarError::ComputationFailed {
1406        operation: "variable_selection_ls cholesky",
1407        detail: "design matrix is singular".to_string(),
1408    })?;
1409    let flat_coeffs = super::cholesky_forward_back(&l, &xty, total_cols);
1410
1411    // Split coefficients back into groups
1412    let mut coefficients: Vec<Vec<f64>> = Vec::with_capacity(big_p + 1);
1413    let mut offset = 0;
1414    for &k_g in &k_sizes {
1415        coefficients.push(flat_coeffs[offset..offset + k_g].to_vec());
1416        offset += k_g;
1417    }
1418    // Scalar covariate coefficients
1419    coefficients.push(flat_coeffs[offset..offset + p_scalar].to_vec());
1420
1421    let fitted_values = compute_varselect_fitted(
1422        n,
1423        mu_y,
1424        &score_groups,
1425        &coefficients,
1426        scalar_covariates,
1427        big_p,
1428    );
1429    let residuals: Vec<f64> = y
1430        .iter()
1431        .zip(&fitted_values)
1432        .map(|(&yi, &yh)| yi - yh)
1433        .collect();
1434    let (r_squared, _) = super::compute_r_squared(y, &residuals, total_cols);
1435    let active_predictors = vec![true; big_p];
1436    Ok(VarSelectResult {
1437        active_predictors,
1438        coefficients,
1439        fitted_values,
1440        residuals,
1441        intercept: mu_y,
1442        lambda: 0.0,
1443        r_squared,
1444        iterations: 1,
1445        converged: true,
1446        fpcas,
1447    })
1448}
1449
1450/// Compute fitted values from variable_selection coefficient structure.
1451fn compute_varselect_fitted(
1452    n: usize,
1453    mu_y: f64,
1454    score_groups: &[Vec<Vec<f64>>],
1455    coefficients: &[Vec<f64>],
1456    scalar_covariates: Option<&FdMatrix>,
1457    big_p: usize,
1458) -> Vec<f64> {
1459    let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
1460    (0..n)
1461        .map(|i| {
1462            let mut yhat = mu_y;
1463            for p in 0..big_p {
1464                for (k, col) in score_groups[p].iter().enumerate() {
1465                    yhat += coefficients[p][k] * col[i];
1466                }
1467            }
1468            if let Some(sc) = scalar_covariates {
1469                for j in 0..p_scalar {
1470                    yhat += coefficients[big_p][j] * sc[(i, j)];
1471                }
1472            }
1473            yhat
1474        })
1475        .collect()
1476}
1477
1478/// Select lambda via 5-fold cross-validation for group lasso.
1479///
1480/// Evaluates a geometric grid of lambda values from `0.01·lambda_max` to `lambda_max`
1481/// and returns the lambda with the lowest 5-fold cross-validated mean squared error.
1482///
1483/// Each fold trains on 4/5 of the data and evaluates prediction error on the held-out
1484/// 1/5. This avoids the monotone-MSE trap of training-set evaluation (training MSE is
1485/// non-increasing as λ decreases, so it always selects the smallest λ).
1486#[allow(clippy::too_many_arguments)]
1487fn select_group_lasso_lambda(
1488    y: &[f64],
1489    _y_centered: &[f64],
1490    _mu_y: f64,
1491    n: usize,
1492    score_groups: &[Vec<Vec<f64>>],
1493    _k_sizes: &[usize],
1494    lambda_max: f64,
1495    n_grid: usize,
1496    max_iter: usize,
1497    epsilon: f64,
1498    scalar_covariates: Option<&FdMatrix>,
1499) -> f64 {
1500    let grid_size = n_grid.max(2);
1501    let big_p = score_groups.len();
1502
1503    // Use min(5, n) folds; degrade gracefully when n is tiny.
1504    let n_folds = 5_usize.min(n).max(2);
1505
1506    // Build fold assignments: observation i goes to fold i % n_folds.
1507    // This gives roughly equal-sized folds without randomisation (deterministic).
1508    let fold_of: Vec<usize> = (0..n).map(|i| i % n_folds).collect();
1509
1510    let mut best_lambda = lambda_max * 0.1;
1511    let mut best_cv_err = f64::INFINITY;
1512
1513    for gi in 0..grid_size {
1514        let frac = (gi as f64 + 1.0) / grid_size as f64;
1515        let lam = lambda_max * (0.01_f64.powf(1.0 - frac)); // geometric: 0.01*lmax..lmax
1516
1517        let mut cv_sq_err = 0.0_f64;
1518        let mut cv_count = 0usize;
1519
1520        for fold in 0..n_folds {
1521            // Split indices into train / validation
1522            let train_idx: Vec<usize> = (0..n).filter(|&i| fold_of[i] != fold).collect();
1523            let val_idx: Vec<usize> = (0..n).filter(|&i| fold_of[i] == fold).collect();
1524            if train_idx.is_empty() || val_idx.is_empty() {
1525                continue;
1526            }
1527
1528            let n_tr = train_idx.len();
1529            let mu_tr = train_idx.iter().map(|&i| y[i]).sum::<f64>() / n_tr as f64;
1530            let y_tr_centered: Vec<f64> = train_idx.iter().map(|&i| y[i] - mu_tr).collect();
1531            let y_tr: Vec<f64> = train_idx.iter().map(|&i| y[i]).collect();
1532
1533            // Build score_groups restricted to train rows
1534            let sg_tr: Vec<Vec<Vec<f64>>> = score_groups
1535                .iter()
1536                .map(|grp| {
1537                    grp.iter()
1538                        .map(|col| train_idx.iter().map(|&i| col[i]).collect())
1539                        .collect()
1540                })
1541                .collect();
1542
1543            // Build scalar_covariates restricted to train rows (column-major FdMatrix)
1544            let sc_tr_mat: Option<FdMatrix> = scalar_covariates.and_then(|sc| {
1545                let p_sc = sc.ncols();
1546                let mut cm = vec![0.0_f64; n_tr * p_sc];
1547                for (row, &orig_i) in train_idx.iter().enumerate() {
1548                    for j in 0..p_sc {
1549                        cm[j * n_tr + row] = sc[(orig_i, j)];
1550                    }
1551                }
1552                FdMatrix::from_column_major(cm, n_tr, p_sc).ok()
1553            });
1554
1555            let k_sizes_tr: Vec<usize> = sg_tr.iter().map(|g| g.len()).collect();
1556
1557            let fit_result = group_lasso_cd(
1558                &y_tr,
1559                &y_tr_centered,
1560                mu_tr,
1561                n_tr,
1562                &sg_tr,
1563                &k_sizes_tr,
1564                lam,
1565                max_iter,
1566                epsilon,
1567                sc_tr_mat.as_ref(),
1568            );
1569
1570            if let Ok((coeffs_tr, _, _)) = fit_result {
1571                // Predict on validation fold using train-fold coefficients
1572                for &i in &val_idx {
1573                    let mut yhat = mu_tr;
1574                    for p in 0..big_p {
1575                        for (k, col) in score_groups[p].iter().enumerate() {
1576                            yhat += coeffs_tr[p][k] * col[i];
1577                        }
1578                    }
1579                    if let Some(sc) = scalar_covariates {
1580                        let p_sc = sc.ncols();
1581                        for j in 0..p_sc {
1582                            yhat += coeffs_tr[big_p][j] * sc[(i, j)];
1583                        }
1584                    }
1585                    let err = y[i] - yhat;
1586                    cv_sq_err += err * err;
1587                    cv_count += 1;
1588                }
1589            }
1590        }
1591
1592        if cv_count > 0 {
1593            let cv_mse = cv_sq_err / cv_count as f64;
1594            if cv_mse < best_cv_err {
1595                best_cv_err = cv_mse;
1596                best_lambda = lam;
1597            }
1598        }
1599    }
1600    best_lambda
1601}
1602
1603/// Group-lasso coordinate descent.
1604///
1605/// Returns `(coefficients, iterations, converged)` where `coefficients` is a
1606/// `Vec<Vec<f64>>` of length `big_p + 1`; the last entry holds scalar
1607/// covariate coefficients (may be empty).
1608#[allow(clippy::too_many_arguments)]
1609fn group_lasso_cd(
1610    _y: &[f64],
1611    y_centered: &[f64],
1612    _mu_y: f64,
1613    n: usize,
1614    score_groups: &[Vec<Vec<f64>>],
1615    k_sizes: &[usize],
1616    lambda: f64,
1617    max_iter: usize,
1618    epsilon: f64,
1619    scalar_covariates: Option<&FdMatrix>,
1620) -> Result<(Vec<Vec<f64>>, usize, bool), FdarError> {
1621    let big_p = score_groups.len();
1622    let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
1623
1624    // Initialize all group coefficients at zero
1625    let mut beta_groups: Vec<Vec<f64>> = score_groups
1626        .iter()
1627        .map(|grp| vec![0.0_f64; grp.len()])
1628        .collect();
1629    let mut beta_scalar: Vec<f64> = vec![0.0_f64; p_scalar];
1630
1631    let mut converged = false;
1632    let mut iterations = 0;
1633
1634    for _iter in 0..max_iter {
1635        let mut max_delta = 0.0_f64;
1636
1637        // Update each functional predictor group
1638        for p in 0..big_p {
1639            let k_g = k_sizes[p];
1640            let group = &score_groups[p];
1641
1642            // Build partial residual: y - mu_y - sum_{q != p} X_q beta_q - Z beta_z
1643            let partial: Vec<f64> = (0..n)
1644                .map(|i| {
1645                    let mut res = y_centered[i];
1646                    for q in 0..big_p {
1647                        if q != p {
1648                            for (k, col) in score_groups[q].iter().enumerate() {
1649                                res -= beta_groups[q][k] * col[i];
1650                            }
1651                        }
1652                    }
1653                    if let Some(sc) = scalar_covariates {
1654                        for j in 0..p_scalar {
1655                            res -= beta_scalar[j] * sc[(i, j)];
1656                        }
1657                    }
1658                    res
1659                })
1660                .collect();
1661
1662            // OLS update for this group: beta_g_ols = (X_g'X_g)^{-1} X_g' partial
1663            // Build X_g'X_g (k_g × k_g) and X_g'partial
1664            let mut xtx_g = vec![0.0_f64; k_g * k_g];
1665            let mut xty_g = vec![0.0_f64; k_g];
1666            for a in 0..k_g {
1667                for b in 0..k_g {
1668                    let dot: f64 = group[a]
1669                        .iter()
1670                        .zip(&group[b])
1671                        .map(|(&xa, &xb)| xa * xb)
1672                        .sum();
1673                    xtx_g[a * k_g + b] = dot;
1674                }
1675                xty_g[a] = group[a]
1676                    .iter()
1677                    .zip(&partial)
1678                    .map(|(&xa, &pa)| xa * pa)
1679                    .sum();
1680            }
1681
1682            let beta_ols =
1683                crate::linalg::cholesky_solve(&xtx_g, &xty_g, k_g).unwrap_or_else(|_| {
1684                    // Cholesky failed: X_g'X_g is (near-)singular.
1685                    // Add ridge regularization: solve (X_g'X_g + δI) β = X_g' partial.
1686                    // δ is 1e-6 × (mean diagonal) to keep the scale relative to the data.
1687                    let diag_sum: f64 = (0..k_g).map(|d| xtx_g[d * k_g + d].abs()).sum();
1688                    let delta = (1e-6 * diag_sum / k_g as f64).max(1e-8);
1689                    let mut xtx_ridge = xtx_g.clone();
1690                    for d in 0..k_g {
1691                        xtx_ridge[d * k_g + d] += delta;
1692                    }
1693                    crate::linalg::cholesky_solve(&xtx_ridge, &xty_g, k_g)
1694                        .unwrap_or_else(|_| vec![0.0; k_g]) // final fallback: zero-out group
1695                });
1696
1697            // Group-lasso soft threshold
1698            let norm_ols: f64 = beta_ols.iter().map(|&b| b * b).sum::<f64>().sqrt();
1699            let threshold = lambda * (k_g as f64).sqrt();
1700            let scale = if norm_ols > 1e-15 {
1701                (1.0 - threshold / norm_ols).max(0.0)
1702            } else {
1703                0.0
1704            };
1705
1706            let new_beta: Vec<f64> = beta_ols.iter().map(|&b| b * scale).collect();
1707
1708            // Track max change
1709            let delta = new_beta
1710                .iter()
1711                .zip(&beta_groups[p])
1712                .map(|(&nb, &ob)| (nb - ob).abs())
1713                .fold(0.0_f64, f64::max);
1714            max_delta = max_delta.max(delta);
1715            beta_groups[p] = new_beta;
1716        }
1717
1718        // Update scalar covariate coefficients (no group penalty — standard OLS)
1719        if let Some(sc) = scalar_covariates {
1720            for j in 0..p_scalar {
1721                let partial_j: Vec<f64> = (0..n)
1722                    .map(|i| {
1723                        let mut res = y_centered[i];
1724                        for p in 0..big_p {
1725                            for (k, col) in score_groups[p].iter().enumerate() {
1726                                res -= beta_groups[p][k] * col[i];
1727                            }
1728                        }
1729                        for jj in 0..p_scalar {
1730                            if jj != j {
1731                                res -= beta_scalar[jj] * sc[(i, jj)];
1732                            }
1733                        }
1734                        res
1735                    })
1736                    .collect();
1737                let col_j: Vec<f64> = (0..n).map(|i| sc[(i, j)]).collect();
1738                let xjxj: f64 = col_j.iter().map(|&v| v * v).sum();
1739                let xjy: f64 = col_j.iter().zip(&partial_j).map(|(&x, &p)| x * p).sum();
1740                let new_bj = if xjxj > 1e-15 { xjy / xjxj } else { 0.0 };
1741                let delta = (new_bj - beta_scalar[j]).abs();
1742                max_delta = max_delta.max(delta);
1743                beta_scalar[j] = new_bj;
1744            }
1745        }
1746
1747        iterations = _iter + 1;
1748        if max_delta < epsilon {
1749            converged = true;
1750            break;
1751        }
1752    }
1753
1754    let mut coefficients: Vec<Vec<f64>> = beta_groups;
1755    coefficients.push(beta_scalar);
1756    Ok((coefficients, iterations, converged))
1757}
1758
1759/// Permutation significance test for the Functional Additive Model ([`fam`]).
1760///
1761/// Assesses whether the additive relationship between the functional predictor
1762/// `data` and the scalar response `y` is statistically significant by comparing
1763/// the observed test statistic to a null distribution obtained by randomly
1764/// permuting `y`.
1765///
1766/// # Algorithm
1767///
1768/// 1. Fit [`fam`] on the original `(data, y)` → `T_obs`.
1769/// 2. Seed a single [`rand::rngs::StdRng`] with `perm_config.seed`.
1770/// 3. For each of `n_perm` iterations: clone `y`, shuffle the clone with `rng`,
1771///    refit FAM, compute `T_perm`. The single RNG advances deterministically
1772///    across iterations — this is NOT the per-thread `seed + k` seeding used
1773///    in parallel rayon loops.
1774/// 4. `p_value = (n_ge + 1) / (n_perm + 1)` (Phipson & Smyth 2010).
1775///
1776/// # Test Statistics
1777///
1778/// - [`PermTestStatistic::R2`] (default): R² of the fitted model.
1779/// - [`PermTestStatistic::FittedNorm`]: L2 norm of fitted values.
1780/// - [`PermTestStatistic::ComponentNorm`]: sum of per-component fit norms.
1781///
1782/// # Arguments
1783/// * `data` — Functional predictor matrix (n × m).
1784/// * `y` — Scalar response (length n).
1785/// * `argvals` — Evaluation grid (length m).
1786/// * `scalar_covariates` — Optional scalar covariates (n × p).
1787/// * `config` — FAM tuning parameters; see [`FamConfig`].
1788/// * `perm_config` — Permutation test configuration; see [`PermTestConfig`].
1789///
1790/// # Errors
1791///
1792/// Propagates [`FdarError`] from the initial `fam` fit. Individual permutation
1793/// errors are absorbed into `n_perm_success` (failed refits are skipped).
1794///
1795/// # Examples
1796///
1797/// ```
1798/// use fdars_core::matrix::FdMatrix;
1799/// use fdars_core::{permutation_test_fam, fam};
1800/// use fdars_core::scalar_on_function::{FamConfig, PermTestConfig, PermTestStatistic};
1801///
1802/// let n = 25;
1803/// let m = 10;
1804/// let data = FdMatrix::from_column_major(
1805///     (0..n*m).map(|i| (i as f64 * 0.1).sin()).collect(),
1806///     n, m,
1807/// ).unwrap();
1808/// let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1809/// let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).cos()).collect();
1810/// let mut fam_cfg = FamConfig::default();
1811/// fam_cfg.ncomp = 2;
1812/// let mut perm_cfg = PermTestConfig::default();
1813/// perm_cfg.n_perm = 9;
1814/// perm_cfg.seed = 42;
1815/// perm_cfg.statistic = PermTestStatistic::R2;
1816/// let result = permutation_test_fam(&data, &y, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
1817/// assert!((0.0..=1.0).contains(&result.p_value));
1818/// ```
1819#[must_use = "expensive computation whose result should not be discarded"]
1820pub fn permutation_test_fam(
1821    data: &FdMatrix,
1822    y: &[f64],
1823    argvals: &[f64],
1824    scalar_covariates: Option<&FdMatrix>,
1825    config: &FamConfig,
1826    perm_config: &PermTestConfig,
1827) -> Result<PermTestResult, FdarError> {
1828    if perm_config.n_perm == 0 {
1829        return Err(FdarError::InvalidParameter {
1830            parameter: "perm_config.n_perm",
1831            message: "n_perm must be >= 1 for a meaningful permutation test".to_string(),
1832        });
1833    }
1834
1835    // Fit on original data first (propagates FdarError on failure)
1836    let original_fit = fam(data, y, argvals, scalar_covariates, config)?;
1837
1838    let observed_statistic = extract_perm_stat(&original_fit, perm_config.statistic);
1839
1840    // Seeded RNG — place use inside function body per clippy/unused-import rule
1841    use rand::prelude::*;
1842    let mut rng = StdRng::seed_from_u64(perm_config.seed);
1843
1844    let n_perm = perm_config.n_perm;
1845    let mut null_statistics: Vec<f64> = Vec::with_capacity(n_perm);
1846    let mut n_ge = 0usize;
1847    let mut n_perm_success = 0usize;
1848
1849    let mut y_perm: Vec<f64> = y.to_vec();
1850
1851    for _ in 0..n_perm {
1852        // Shuffle only y — reuse the same predictor buffers
1853        y_perm.copy_from_slice(y);
1854        y_perm.shuffle(&mut rng);
1855
1856        match fam(data, &y_perm, argvals, scalar_covariates, config) {
1857            Ok(perm_fit) => {
1858                let t_perm = extract_perm_stat(&perm_fit, perm_config.statistic);
1859                null_statistics.push(t_perm);
1860                if t_perm >= observed_statistic {
1861                    n_ge += 1;
1862                }
1863                n_perm_success += 1;
1864            }
1865            Err(_) => {
1866                // Skip failed refits (e.g., bandwidth selection on degenerate data)
1867            }
1868        }
1869    }
1870
1871    // Use actual successful refits in both numerator and denominator
1872    // (Phipson & Smyth 2010 corrected for partially-failed permutations)
1873    let p_value = (n_ge + 1) as f64 / (n_perm_success + 1) as f64;
1874
1875    Ok(PermTestResult {
1876        p_value,
1877        observed_statistic,
1878        null_statistics,
1879        n_perm_success,
1880    })
1881}
1882
1883/// Extract the permutation test statistic from a `FamResult`.
1884fn extract_perm_stat(fit: &FamResult, stat: PermTestStatistic) -> f64 {
1885    match stat {
1886        PermTestStatistic::R2 => fit.r_squared,
1887        PermTestStatistic::FittedNorm => {
1888            fit.fitted_values.iter().map(|&v| v * v).sum::<f64>().sqrt()
1889        }
1890        PermTestStatistic::ComponentNorm => fit
1891            .component_fits
1892            .iter()
1893            .map(|cf| cf.iter().map(|&v| v * v).sum::<f64>().sqrt())
1894            .sum::<f64>(),
1895    }
1896}
1897
1898/// History-index scalar-on-function estimator.
1899///
1900/// Models `E{Y_i} = β₀ + β₁ · score_i` where `score_i` is the history index:
1901/// `score_i = Σ_l γ(u_l) · X_i(T − u_l) · Δu`
1902/// with `T = argvals.last()`, `u_l ∈ [0, Δ]` the lag grid, and `γ(·)` the
1903/// history weight function estimated by Nadaraya-Watson on the lag axis.
1904///
1905/// # Algorithm
1906///
1907/// 1. Validate `config.window ≤ argvals range`.
1908/// 2. Discretise lag grid: `u_l = l · Δ / n_lags` for l = 0, …, n_lags−1.
1909/// 3. For each observation i and lag l: extract `X_i(T − u_l)` via nearest-
1910///    lower-bound column lookup with `.min(m−1)` clamping (documented choice:
1911///    nearest-neighbour approximation; linear interpolation is more accurate
1912///    but not needed for the discretised grid resolution in use here).
1913/// 4. Estimate `γ` via `nadaraya_watson` on the lag axis, using `optim_bandwidth`
1914///    GCV when `config.bandwidth == 0.0`.
1915/// 5. Normalise `γ` so `Σ_l γ_l² · Δu ≈ 1` (identifiability).
1916/// 6. Compute `score_i = Σ_l γ_l · x_lag[i,l] · Δu`.
1917/// 7. Fit `E{Y_i} = β₀ + β₁ · score_i` by OLS.
1918///
1919/// # R Baseline Divergence
1920///
1921/// R's `refund::pffr` with `ff(..., limits)` implements the full function-on-
1922/// function history model as a lower-triangular bivariate spline. fdars
1923/// implements the scalar-on-function reduction (scalar Y, history index at
1924/// `T = argvals.last()`) via NW on a discretised lag grid — same model class,
1925/// marginal-integration approximation.
1926///
1927/// # Arguments
1928/// * `data` — Functional predictor matrix (n × m).
1929/// * `y` — Scalar response (length n).
1930/// * `argvals` — Evaluation grid (length m).
1931/// * `config` — Tuning parameters; see [`HistoryIndexConfig`].
1932///
1933/// # Errors
1934///
1935/// Returns [`FdarError::InvalidParameter`] if `config.window > argvals range`.
1936///
1937/// Returns [`FdarError::InvalidDimension`] for shape mismatches.
1938///
1939/// # Examples
1940///
1941/// ```
1942/// use fdars_core::matrix::FdMatrix;
1943/// use fdars_core::history_index;
1944/// use fdars_core::scalar_on_function::HistoryIndexConfig;
1945///
1946/// let n = 30;
1947/// let m = 20;
1948/// let data = FdMatrix::from_column_major(
1949///     (0..n*m).map(|i| (i as f64 * 0.1).sin()).collect(),
1950///     n, m,
1951/// ).unwrap();
1952/// let argvals: Vec<f64> = (0..m).map(|j| j as f64).collect();
1953/// let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.5).sin()).collect();
1954/// let mut config = HistoryIndexConfig::default();
1955/// config.window = 5.0;
1956/// config.n_lags = 10;
1957/// let result = history_index(&data, &y, &argvals, &config).unwrap();
1958/// assert_eq!(result.gamma.len(), 10);
1959/// assert_eq!(result.lag_grid.len(), 10);
1960/// ```
1961#[must_use = "expensive computation whose result should not be discarded"]
1962pub fn history_index(
1963    data: &FdMatrix,
1964    y: &[f64],
1965    argvals: &[f64],
1966    config: &HistoryIndexConfig,
1967) -> Result<HistoryIndexResult, FdarError> {
1968    let (n, m) = data.shape();
1969
1970    // Validate inputs
1971    if n == 0 {
1972        return Err(FdarError::InvalidDimension {
1973            parameter: "data",
1974            expected: "at least 1 row".to_string(),
1975            actual: "0".to_string(),
1976        });
1977    }
1978    if m == 0 {
1979        return Err(FdarError::InvalidDimension {
1980            parameter: "data",
1981            expected: "at least 1 column".to_string(),
1982            actual: "0".to_string(),
1983        });
1984    }
1985    if y.len() != n {
1986        return Err(FdarError::InvalidDimension {
1987            parameter: "y",
1988            expected: format!("{n}"),
1989            actual: format!("{}", y.len()),
1990        });
1991    }
1992    if argvals.len() != m {
1993        return Err(FdarError::InvalidDimension {
1994            parameter: "argvals",
1995            expected: format!("{m}"),
1996            actual: format!("{}", argvals.len()),
1997        });
1998    }
1999
2000    let argvals_min = argvals.first().copied().unwrap_or(0.0);
2001    let argvals_max = argvals.last().copied().unwrap_or(0.0);
2002    let argvals_range = argvals_max - argvals_min;
2003
2004    if config.window <= 0.0 || config.window > argvals_range {
2005        return Err(FdarError::InvalidParameter {
2006            parameter: "config.window",
2007            message: format!(
2008                "window ({:.6}) must be positive and <= argvals range ({:.6})",
2009                config.window, argvals_range
2010            ),
2011        });
2012    }
2013
2014    let n_lags = config.n_lags.max(1);
2015    let delta_u = config.window / n_lags as f64;
2016    let big_t = argvals_max;
2017
2018    // Discretise lag grid: u_l = l * delta_u for l = 0..n_lags
2019    let lag_grid: Vec<f64> = (0..n_lags).map(|l| l as f64 * delta_u).collect();
2020
2021    // Extract lagged covariate values x_lag[i][l] = X_i(T - u_l)
2022    // Using nearest-lower-bound column lookup with min(m-1) clamp.
2023    // Documented choice: nearest-neighbour approximation. For the discretised
2024    // lag grid resolution typical in practice, this is accurate; linear
2025    // interpolation would be more precise but is not required here.
2026    let x_lag: Vec<Vec<f64>> = (0..n)
2027        .map(|i| {
2028            lag_grid
2029                .iter()
2030                .map(|&u_l| {
2031                    let t_target = big_t - u_l;
2032                    // Find the largest j such that argvals[j] <= t_target
2033                    let j = argvals
2034                        .partition_point(|&v| v < t_target)
2035                        .saturating_sub(1)
2036                        .min(m - 1);
2037                    data[(i, j)]
2038                })
2039                .collect()
2040        })
2041        .collect();
2042
2043    // Estimate gamma via nadaraya_watson on the lag axis.
2044    // Use the mean of y as the initial response signal for gamma estimation.
2045    let mu_y = y.iter().sum::<f64>() / n as f64;
2046    let y_centered: Vec<f64> = y.iter().map(|&yi| yi - mu_y).collect();
2047
2048    // Compute a rough initial gamma: for each lag l, correlate x_lag[:,l] with y_centered
2049    // to get an initial signal for NW.
2050    let gamma_signal: Vec<f64> = lag_grid
2051        .iter()
2052        .enumerate()
2053        .map(|(l, _)| {
2054            let x_col: Vec<f64> = (0..n).map(|i| x_lag[i][l]).collect();
2055            let x_mean = x_col.iter().sum::<f64>() / n as f64;
2056            let xx: f64 = x_col.iter().map(|&v| (v - x_mean).powi(2)).sum();
2057            let xy: f64 = x_col
2058                .iter()
2059                .zip(&y_centered)
2060                .map(|(&x, &yc)| (x - x_mean) * yc)
2061                .sum();
2062            if xx > 1e-15 {
2063                xy / xx
2064            } else {
2065                0.0
2066            }
2067        })
2068        .collect();
2069
2070    // Smooth gamma_signal via NW on the lag axis
2071    let h_gamma = if config.bandwidth > 0.0 {
2072        config.bandwidth
2073    } else {
2074        let bw_result = optim_bandwidth(
2075            &lag_grid,
2076            &gamma_signal,
2077            None,
2078            CvCriterion::Gcv,
2079            &config.kernel,
2080            20,
2081        );
2082        bw_result.h_opt.max(delta_u) // at least one lag step
2083    };
2084
2085    let gamma_raw = nadaraya_watson(&lag_grid, &gamma_signal, &lag_grid, h_gamma, &config.kernel)?;
2086
2087    // Normalise gamma so that Σ_l gamma_l^2 * delta_u ≈ 1 (identifiability)
2088    let norm_sq: f64 = gamma_raw.iter().map(|&g| g * g).sum::<f64>() * delta_u;
2089    let norm = norm_sq.sqrt();
2090    let gamma: Vec<f64> = if norm > 1e-15 {
2091        gamma_raw.iter().map(|&g| g / norm).collect()
2092    } else {
2093        vec![1.0 / (n_lags as f64).sqrt(); n_lags]
2094    };
2095
2096    // Compute history scores: score_i = Σ_l gamma_l * x_lag[i,l] * delta_u
2097    let history_scores: Vec<f64> = (0..n)
2098        .map(|i| {
2099            gamma
2100                .iter()
2101                .enumerate()
2102                .map(|(l, &g)| g * x_lag[i][l] * delta_u)
2103                .sum()
2104        })
2105        .collect();
2106
2107    // OLS: fit E{Y_i} = beta_0 + beta_1 * score_i
2108    let score_mean = history_scores.iter().sum::<f64>() / n as f64;
2109    let sxx: f64 = history_scores
2110        .iter()
2111        .map(|&s| (s - score_mean).powi(2))
2112        .sum();
2113    let sxy: f64 = history_scores
2114        .iter()
2115        .zip(y.iter())
2116        .map(|(&s, &yi)| (s - score_mean) * yi)
2117        .sum();
2118    let slope = if sxx > 1e-15 { sxy / sxx } else { 0.0 };
2119    let intercept = mu_y - slope * score_mean;
2120
2121    let fitted_values: Vec<f64> = history_scores
2122        .iter()
2123        .map(|&s| intercept + slope * s)
2124        .collect();
2125    let residuals: Vec<f64> = y
2126        .iter()
2127        .zip(&fitted_values)
2128        .map(|(&yi, &yh)| yi - yh)
2129        .collect();
2130    let (r_squared, _) = super::compute_r_squared(y, &residuals, 2);
2131
2132    Ok(HistoryIndexResult {
2133        fitted_values,
2134        residuals,
2135        intercept,
2136        slope,
2137        gamma,
2138        lag_grid,
2139        history_scores,
2140        r_squared,
2141    })
2142}
2143
2144// ---------------------------------------------------------------------------
2145// Tests
2146// ---------------------------------------------------------------------------
2147
2148#[cfg(test)]
2149mod tests {
2150    use super::*;
2151    use crate::test_helpers::uniform_grid;
2152
2153    /// Build a synthetic FdMatrix from sinusoidal curves.
2154    fn make_sine_data(n: usize, m: usize, freq_scale: f64) -> FdMatrix {
2155        let data: Vec<f64> = (0..n)
2156            .flat_map(|i| {
2157                (0..m).map(move |j| {
2158                    let t = j as f64 / (m - 1) as f64;
2159                    (freq_scale * (i as f64 + 1.0) * t).sin()
2160                })
2161            })
2162            .collect();
2163        // column-major: column j contains all n observations at time-point j
2164        let mut cm = vec![0.0_f64; n * m];
2165        for i in 0..n {
2166            for j in 0..m {
2167                cm[j * n + i] = data[i * m + j];
2168            }
2169        }
2170        FdMatrix::from_column_major(cm, n, m).unwrap()
2171    }
2172
2173    // -----------------------------------------------------------------------
2174    // FAM tests
2175    // -----------------------------------------------------------------------
2176
2177    #[test]
2178    fn fam_synthetic_recovery() {
2179        // y_i = sin(xi_1) + xi_2^2 + noise — FAM with 2 FPC components should recover.
2180        let n = 50;
2181        let m = 20;
2182        let argvals = uniform_grid(m);
2183
2184        // Generate curves as sine waves with random phase proxy (deterministic)
2185        let data = make_sine_data(n, m, 1.0);
2186        // Extract scores by running FPCA; build y from known structure
2187        let fpca = fdata_to_pc_1d(&data, 2, &argvals).unwrap();
2188        let y: Vec<f64> = (0..n)
2189            .map(|i| {
2190                let xi1 = fpca.scores[(i, 0)];
2191                let xi2 = fpca.scores[(i, 1)];
2192                // Small noise proportional to score range to keep SNR high
2193                let noise = (i as f64 * 0.31).sin() * 0.05;
2194                xi1.sin() + xi2 * xi2 + noise
2195            })
2196            .collect();
2197
2198        let config = FamConfig {
2199            ncomp: 2,
2200            bandwidth: 0.0,
2201            ..Default::default()
2202        };
2203        let result = fam(&data, &y, &argvals, None, &config).unwrap();
2204
2205        // R² should be substantially above a mean-only baseline
2206        assert!(
2207            result.r_squared > 0.75,
2208            "expected R² > 0.75, got {}",
2209            result.r_squared
2210        );
2211
2212        // Relative fitted error < 30%
2213        let y_mean = y.iter().sum::<f64>() / n as f64;
2214        let ss_y: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum::<f64>();
2215        let ss_res: f64 = result.residuals.iter().map(|r| r * r).sum();
2216        let rel_err = (ss_res / ss_y).sqrt();
2217        assert!(
2218            rel_err < 0.30,
2219            "expected relative fitted error < 0.30, got {rel_err:.4}"
2220        );
2221    }
2222
2223    #[test]
2224    fn fam_decomposition_identity() {
2225        // fitted_values + residuals == y elementwise (within 1e-9)
2226        let n = 30;
2227        let m = 15;
2228        let argvals = uniform_grid(m);
2229        let data = make_sine_data(n, m, 1.5);
2230        let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).cos()).collect();
2231        let config = FamConfig {
2232            ncomp: 2,
2233            ..Default::default()
2234        };
2235        let result = fam(&data, &y, &argvals, None, &config).unwrap();
2236
2237        for i in 0..n {
2238            let reconstructed = result.fitted_values[i] + result.residuals[i];
2239            assert!(
2240                (reconstructed - y[i]).abs() < 1e-9,
2241                "decomposition failed at i={i}: fitted={} residual={} sum={} y={}",
2242                result.fitted_values[i],
2243                result.residuals[i],
2244                reconstructed,
2245                y[i]
2246            );
2247        }
2248    }
2249
2250    #[test]
2251    fn fam_output_shapes() {
2252        let n = 25;
2253        let m = 12;
2254        let argvals = uniform_grid(m);
2255        let data = make_sine_data(n, m, 1.0);
2256        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
2257        let config = FamConfig {
2258            ncomp: 3,
2259            ..Default::default()
2260        };
2261        let result = fam(&data, &y, &argvals, None, &config).unwrap();
2262
2263        assert_eq!(result.ncomp, 3, "ncomp field should be 3");
2264        assert_eq!(
2265            result.component_fits.len(),
2266            3,
2267            "component_fits.len() should equal ncomp"
2268        );
2269        for (k, cf) in result.component_fits.iter().enumerate() {
2270            assert_eq!(cf.len(), n, "component_fits[{k}] should have length n={n}");
2271        }
2272        assert_eq!(
2273            result.bandwidths.len(),
2274            3,
2275            "bandwidths.len() should equal ncomp"
2276        );
2277        assert_eq!(result.fitted_values.len(), n);
2278        assert_eq!(result.residuals.len(), n);
2279    }
2280
2281    #[test]
2282    fn fam_invalid_dimension() {
2283        let n = 20;
2284        let m = 10;
2285        let argvals = uniform_grid(m);
2286        let data = make_sine_data(n, m, 1.0);
2287        let y_ok: Vec<f64> = (0..n).map(|i| i as f64).collect();
2288        let config = FamConfig {
2289            ncomp: 2,
2290            ..Default::default()
2291        };
2292
2293        // Empty FdMatrix (0 rows)
2294        let empty_data = FdMatrix::zeros(0, m);
2295        let err = fam(&empty_data, &y_ok, &argvals, None, &config);
2296        assert!(err.is_err(), "empty data should return Err");
2297        match err.unwrap_err() {
2298            FdarError::InvalidDimension { parameter, .. } => {
2299                assert_eq!(parameter, "data");
2300            }
2301            e => panic!("expected InvalidDimension, got {e:?}"),
2302        }
2303
2304        // y of wrong length
2305        let y_wrong: Vec<f64> = vec![1.0; n + 5];
2306        let err = fam(&data, &y_wrong, &argvals, None, &config);
2307        assert!(err.is_err(), "mismatched y length should return Err");
2308        match err.unwrap_err() {
2309            FdarError::InvalidDimension { parameter, .. } => {
2310                assert_eq!(parameter, "y");
2311            }
2312            e => panic!("expected InvalidDimension, got {e:?}"),
2313        }
2314
2315        // argvals of wrong length
2316        let argvals_wrong: Vec<f64> = uniform_grid(m + 3);
2317        let err = fam(&data, &y_ok, &argvals_wrong, None, &config);
2318        assert!(err.is_err(), "mismatched argvals should return Err");
2319        match err.unwrap_err() {
2320            FdarError::InvalidDimension { parameter, .. } => {
2321                assert_eq!(parameter, "argvals");
2322            }
2323            e => panic!("expected InvalidDimension, got {e:?}"),
2324        }
2325    }
2326
2327    // -----------------------------------------------------------------------
2328    // GKAM tests
2329    // -----------------------------------------------------------------------
2330
2331    #[test]
2332    fn gkam_r2_synthetic() {
2333        // One functional covariate; y is a pure function of the L2 norm of X (+ tiny noise).
2334        // The L2 distance kernel in GKAM should recover this functional dependence well.
2335        let n = 40;
2336        let m = 15;
2337        let argvals = uniform_grid(m);
2338
2339        // Curves with varying amplitude: curve i has amplitude proportional to i
2340        let mut cm = vec![0.0_f64; n * m];
2341        for i in 0..n {
2342            let amp = (i as f64 + 1.0) / n as f64; // amplitude 1/n … 1
2343            for j in 0..m {
2344                let t = j as f64 / (m - 1) as f64;
2345                // column-major: index = j*n + i
2346                cm[j * n + i] = amp * (std::f64::consts::PI * 2.0 * t).sin();
2347            }
2348        }
2349        let data = FdMatrix::from_column_major(cm, n, m).unwrap();
2350
2351        // y is a monotone function of the amplitude (== L2 norm up to constant factor)
2352        // So GKAM on L2 distances should recover this very well.
2353        let y: Vec<f64> = (0..n)
2354            .map(|i| {
2355                let amp = (i as f64 + 1.0) / n as f64;
2356                // y = amp^2 (nonlinear in amp but determined by it — R² should be high)
2357                let noise = (i as f64 * 0.23).sin() * 0.002;
2358                amp * amp + noise
2359            })
2360            .collect();
2361
2362        let config = GkamConfig {
2363            max_iter: 20,
2364            epsilon: 1e-4,
2365            ..Default::default()
2366        };
2367        let result = fregre_gkam(&[&data], &y, &[&argvals], None, &config).unwrap();
2368
2369        assert!(
2370            result.r_squared > 0.70,
2371            "expected R² > 0.70, got {}",
2372            result.r_squared
2373        );
2374    }
2375
2376    #[test]
2377    fn gkam_convergence() {
2378        // On smooth data GKAM should converge within max_iter iterations
2379        let n = 25;
2380        let m = 10;
2381        let argvals = uniform_grid(m);
2382        let data = make_sine_data(n, m, 1.0);
2383        let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
2384
2385        let config = GkamConfig {
2386            max_iter: 50,
2387            epsilon: 1e-4,
2388            ..Default::default()
2389        };
2390        let result = fregre_gkam(&[&data], &y, &[&argvals], None, &config).unwrap();
2391
2392        assert!(
2393            result.converged,
2394            "expected convergence, got iterations={}",
2395            result.iterations
2396        );
2397        assert!(
2398            result.iterations <= config.max_iter,
2399            "iterations {} > max_iter {}",
2400            result.iterations,
2401            config.max_iter
2402        );
2403    }
2404
2405    #[test]
2406    fn gkam_invalid_inputs() {
2407        let n = 20;
2408        let m = 10;
2409        let argvals = uniform_grid(m);
2410        let data = make_sine_data(n, m, 1.0);
2411        let y_ok: Vec<f64> = (0..n).map(|i| i as f64).collect();
2412        let config = GkamConfig::default();
2413
2414        // Empty predictors list
2415        let err = fregre_gkam(&[], &y_ok, &[], None, &config);
2416        assert!(err.is_err(), "empty predictors should return Err");
2417
2418        // Mismatched predictor/y lengths
2419        let data_wrong = make_sine_data(n + 5, m, 1.0);
2420        let err = fregre_gkam(&[&data_wrong], &y_ok, &[&argvals], None, &config);
2421        assert!(err.is_err(), "mismatched n should return Err");
2422        match err.unwrap_err() {
2423            FdarError::InvalidDimension { .. } => {}
2424            e => panic!("expected InvalidDimension, got {e:?}"),
2425        }
2426
2427        // argvals_list length mismatch
2428        let err = fregre_gkam(&[&data], &y_ok, &[], None, &config);
2429        assert!(
2430            err.is_err(),
2431            "argvals_list length mismatch should return Err"
2432        );
2433    }
2434
2435    // -----------------------------------------------------------------------
2436    // GSAM tests
2437    // -----------------------------------------------------------------------
2438
2439    #[test]
2440    fn gsam_matches_fam_identity() {
2441        // With identical config, gsam and fam should produce the same fitted values (within 1e-6).
2442        let n = 40;
2443        let m = 16;
2444        let argvals = uniform_grid(m);
2445        let data = make_sine_data(n, m, 1.0);
2446        let fpca_ref = fdata_to_pc_1d(&data, 2, &argvals).unwrap();
2447        let y: Vec<f64> = (0..n)
2448            .map(|i| {
2449                let xi1 = fpca_ref.scores[(i, 0)];
2450                let xi2 = fpca_ref.scores[(i, 1)];
2451                xi1 + xi2 * xi2 + (i as f64 * 0.23).sin() * 0.02
2452            })
2453            .collect();
2454
2455        let fam_config = FamConfig {
2456            ncomp: 2,
2457            bandwidth: 0.5, // fixed bandwidth for deterministic comparison
2458            kernel: "gaussian".to_string(),
2459            n_grid_bandwidth: 20,
2460        };
2461        let gsam_config = GsamConfig {
2462            ncomp: 2,
2463            bandwidth: 0.5,
2464            kernel: "gaussian".to_string(),
2465            n_grid_bandwidth: 20,
2466        };
2467
2468        let fam_res = fam(&data, &y, &argvals, None, &fam_config).unwrap();
2469        let gsam_res = fregre_gsam(&data, &y, &argvals, None, &gsam_config).unwrap();
2470
2471        for i in 0..n {
2472            let diff = (fam_res.fitted_values[i] - gsam_res.fitted_values[i]).abs();
2473            assert!(
2474                diff < 1e-6,
2475                "fam vs gsam mismatch at i={i}: fam={} gsam={} diff={diff:.2e}",
2476                fam_res.fitted_values[i],
2477                gsam_res.fitted_values[i]
2478            );
2479        }
2480    }
2481
2482    #[test]
2483    fn gsam_ncomp_too_large() {
2484        let n = 15;
2485        let m = 8;
2486        let argvals = uniform_grid(m);
2487        let data = make_sine_data(n, m, 1.0);
2488        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
2489
2490        // ncomp > min(n, m) = 8
2491        let config = GsamConfig {
2492            ncomp: 100,
2493            ..Default::default()
2494        };
2495        let err = fregre_gsam(&data, &y, &argvals, None, &config);
2496        assert!(err.is_err(), "ncomp > min(n,m) should return Err");
2497        match err.unwrap_err() {
2498            FdarError::InvalidParameter { parameter, .. } => {
2499                assert_eq!(parameter, "config.ncomp");
2500            }
2501            e => panic!("expected InvalidParameter, got {e:?}"),
2502        }
2503    }
2504
2505    #[test]
2506    fn gsam_output_shapes() {
2507        let n = 30;
2508        let m = 10;
2509        let argvals = uniform_grid(m);
2510        let data = make_sine_data(n, m, 1.0);
2511        let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
2512
2513        let config = GsamConfig {
2514            ncomp: 3,
2515            ..Default::default()
2516        };
2517        let result = fregre_gsam(&data, &y, &argvals, None, &config).unwrap();
2518
2519        assert_eq!(result.ncomp, 3);
2520        assert_eq!(
2521            result.component_fits.len(),
2522            3,
2523            "component_fits.len() should equal ncomp"
2524        );
2525        assert_eq!(result.fitted_values.len(), n);
2526    }
2527
2528    // -----------------------------------------------------------------------
2529    // variable_selection tests
2530    // -----------------------------------------------------------------------
2531
2532    #[test]
2533    fn varselect_active_subset_recovery() {
2534        // 5 functional predictors with orthogonal FPC bases; only predictors
2535        // 0 and 2 are truly active. We build y = 5·s0 + 3·s2 + tiny noise
2536        // where s0 and s2 are the observation amplitudes for predictors 0 and 2.
2537        // The predictors are constructed so that their "FPC score" (amplitude)
2538        // patterns are orthogonal: pred p uses frequency band (p+1)*2, so the
2539        // FPC scores of different predictors are uncorrelated for large enough n.
2540        let n = 100;
2541        let m = 30;
2542        let argvals = uniform_grid(m);
2543
2544        // Build 5 orthogonal-amplitude predictors.
2545        // Each predictor p_i: observation i has amplitude a[i, p] = sin(pi*(p+1)*i/n)
2546        // These are orthogonal amplitude patterns for different p.
2547        let make_orth = |p_idx: usize| -> FdMatrix {
2548            let mut cm = vec![0.0_f64; n * m];
2549            for i in 0..n {
2550                // Amplitude pattern: sin-based, different frequency per predictor
2551                let amp = (std::f64::consts::PI * (p_idx + 1) as f64 * i as f64 / n as f64).sin();
2552                for j in 0..m {
2553                    let t = j as f64 / (m - 1) as f64;
2554                    // Curve shape is fixed (cos), amplitude varies per obs in orth pattern
2555                    cm[j * n + i] = amp * (std::f64::consts::PI * 2.0 * t).cos();
2556                }
2557            }
2558            FdMatrix::from_column_major(cm, n, m).unwrap()
2559        };
2560
2561        let preds: Vec<FdMatrix> = (0..5).map(make_orth).collect();
2562        let pred_refs: Vec<&FdMatrix> = preds.iter().collect();
2563        let argvals_list: Vec<&[f64]> = (0..5).map(|_| argvals.as_slice()).collect();
2564
2565        // The first FPC score of predictor p is essentially its amplitude pattern a[i, p].
2566        // Build y = 5 * a[i,0] + 3 * a[i,2] + tiny noise.
2567        let y: Vec<f64> = (0..n)
2568            .map(|i| {
2569                let a0 = (std::f64::consts::PI * i as f64 / n as f64).sin();
2570                let a2 = (std::f64::consts::PI * 3.0 * i as f64 / n as f64).sin();
2571                5.0 * a0 + 3.0 * a2 + (i as f64 * 0.31).sin() * 0.01
2572            })
2573            .collect();
2574
2575        let config = VarSelectConfig {
2576            ncomp: 1,
2577            lambda_n_grid: 20,
2578            ..Default::default()
2579        };
2580        let result = variable_selection(&pred_refs, &y, &argvals_list, None, &config).unwrap();
2581
2582        assert_eq!(
2583            result.active_predictors.len(),
2584            5,
2585            "should have 5 active_predictors entries"
2586        );
2587        // At least predictors 0 and 2 must be active
2588        assert!(
2589            result.active_predictors[0],
2590            "predictor 0 should be active, got {:?}",
2591            result.active_predictors
2592        );
2593        assert!(
2594            result.active_predictors[2],
2595            "predictor 2 should be active, got {:?}",
2596            result.active_predictors
2597        );
2598        // Inactive predictors 1, 3, 4 should be dropped
2599        assert!(
2600            !result.active_predictors[1],
2601            "predictor 1 should be inactive, got {:?}",
2602            result.active_predictors
2603        );
2604        assert!(
2605            !result.active_predictors[3],
2606            "predictor 3 should be inactive, got {:?}",
2607            result.active_predictors
2608        );
2609        assert!(
2610            !result.active_predictors[4],
2611            "predictor 4 should be inactive, got {:?}",
2612            result.active_predictors
2613        );
2614        // R² should be well above zero
2615        assert!(
2616            result.r_squared > 0.5,
2617            "expected R² > 0.5, got {}",
2618            result.r_squared
2619        );
2620    }
2621
2622    #[test]
2623    fn varselect_lambda_max_zeros() {
2624        // At lambda = lambda_max (or very large lambda), group-lasso should
2625        // zero out all groups (active_predictors all false).
2626        let n = 30;
2627        let m = 10;
2628        let argvals = uniform_grid(m);
2629        let preds: Vec<FdMatrix> = (0..3usize).map(|_| make_sine_data(n, m, 1.0)).collect();
2630        let pred_refs: Vec<&FdMatrix> = preds.iter().collect();
2631        let argvals_list: Vec<&[f64]> = (0..3).map(|_| argvals.as_slice()).collect();
2632        let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
2633
2634        // Use a very large explicit lambda to force all-zero solution
2635        let config = VarSelectConfig {
2636            ncomp: 2,
2637            lambda: 1e6, // massively oversized lambda
2638            ..Default::default()
2639        };
2640        let result = variable_selection(&pred_refs, &y, &argvals_list, None, &config).unwrap();
2641
2642        // With lambda >> lambda_max every group should be zeroed out
2643        assert!(
2644            result.active_predictors.iter().all(|&a| !a),
2645            "expected all inactive at lambda=1e6, got {:?}",
2646            result.active_predictors
2647        );
2648    }
2649
2650    #[test]
2651    fn varselect_invalid_inputs() {
2652        let n = 20;
2653        let m = 10;
2654        let argvals = uniform_grid(m);
2655        let data = make_sine_data(n, m, 1.0);
2656        let y_ok: Vec<f64> = (0..n).map(|i| i as f64).collect();
2657        let config = VarSelectConfig {
2658            ncomp: 2,
2659            ..Default::default()
2660        };
2661
2662        // Empty predictor list
2663        let err = variable_selection(&[], &y_ok, &[], None, &config);
2664        assert!(err.is_err(), "empty predictors should return Err");
2665        match err.unwrap_err() {
2666            FdarError::InvalidDimension { .. } => {}
2667            e => panic!("expected InvalidDimension, got {e:?}"),
2668        }
2669
2670        // Predictor/response length mismatch
2671        let data_wrong = make_sine_data(n + 5, m, 1.0);
2672        let err = variable_selection(&[&data_wrong], &y_ok, &[&argvals], None, &config);
2673        assert!(err.is_err(), "mismatched n should return Err");
2674        match err.unwrap_err() {
2675            FdarError::InvalidDimension { .. } => {}
2676            e => panic!("expected InvalidDimension, got {e:?}"),
2677        }
2678
2679        // argvals_list length mismatch
2680        let err = variable_selection(&[&data], &y_ok, &[], None, &config);
2681        assert!(err.is_err(), "argvals_list mismatch should return Err");
2682
2683        // Unsupported penalty
2684        let config_mcp = VarSelectConfig {
2685            penalty: VarSelectPenalty::GroupMcp,
2686            ..config.clone()
2687        };
2688        let err = variable_selection(&[&data], &y_ok, &[&argvals], None, &config_mcp);
2689        assert!(err.is_err(), "GroupMcp should return Err");
2690        match err.unwrap_err() {
2691            FdarError::InvalidParameter { parameter, .. } => {
2692                assert_eq!(parameter, "config.penalty");
2693            }
2694            e => panic!("expected InvalidParameter, got {e:?}"),
2695        }
2696    }
2697
2698    // -----------------------------------------------------------------------
2699    // permutation_test_fam tests
2700    // -----------------------------------------------------------------------
2701
2702    #[test]
2703    fn perm_seeded_reproducibility() {
2704        // Two calls with the same seed must produce identical p_values.
2705        let n = 30;
2706        let m = 12;
2707        let argvals = uniform_grid(m);
2708        let data = make_sine_data(n, m, 1.0);
2709        let fpca = fdata_to_pc_1d(&data, 1, &argvals).unwrap();
2710        let y: Vec<f64> = (0..n)
2711            .map(|i| fpca.scores[(i, 0)] * 2.0 + (i as f64 * 0.31).sin() * 0.05)
2712            .collect();
2713
2714        let fam_cfg = FamConfig {
2715            ncomp: 1,
2716            ..Default::default()
2717        };
2718        let perm_cfg = PermTestConfig {
2719            n_perm: 19,
2720            seed: 42,
2721            statistic: PermTestStatistic::R2,
2722        };
2723
2724        let r1 = permutation_test_fam(&data, &y, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2725        let r2 = permutation_test_fam(&data, &y, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2726        assert_eq!(
2727            r1.p_value, r2.p_value,
2728            "same seed should give same p_value: {} vs {}",
2729            r1.p_value, r2.p_value
2730        );
2731        assert_eq!(
2732            r1.null_statistics, r2.null_statistics,
2733            "same seed should give same null distribution"
2734        );
2735    }
2736
2737    #[test]
2738    fn perm_pvalue_range() {
2739        // p_value must be in [0, 1] regardless of inputs.
2740        let n = 20;
2741        let m = 8;
2742        let argvals = uniform_grid(m);
2743        let data = make_sine_data(n, m, 1.0);
2744        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
2745        let fam_cfg = FamConfig {
2746            ncomp: 1,
2747            ..Default::default()
2748        };
2749        let perm_cfg = PermTestConfig {
2750            n_perm: 9,
2751            seed: 0,
2752            statistic: PermTestStatistic::FittedNorm,
2753        };
2754        let result = permutation_test_fam(&data, &y, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2755        assert!(
2756            (0.0..=1.0).contains(&result.p_value),
2757            "p_value out of [0,1]: {}",
2758            result.p_value
2759        );
2760    }
2761
2762    #[test]
2763    fn perm_detects_true_effect() {
2764        // y = 2 * xi_1 + tiny noise → should give small p_value under n_perm=99 / seed=42.
2765        // Under the null (y = noise only) p_value should be non-significant.
2766        let n = 40;
2767        let m = 15;
2768        let argvals = uniform_grid(m);
2769        let data = make_sine_data(n, m, 1.0);
2770        let fpca = fdata_to_pc_1d(&data, 1, &argvals).unwrap();
2771
2772        // Strong signal: y = 2 * xi_1 + very small noise
2773        let y_signal: Vec<f64> = (0..n)
2774            .map(|i| {
2775                let xi1 = fpca.scores[(i, 0)];
2776                2.0 * xi1 + (i as f64 * 0.17).sin() * 0.02
2777            })
2778            .collect();
2779
2780        // Pure noise: y = noise (no relationship to predictor)
2781        let y_null: Vec<f64> = (0..n).map(|i| (i as f64 * 0.37).sin() * 0.3).collect();
2782
2783        let fam_cfg = FamConfig {
2784            ncomp: 1,
2785            ..Default::default()
2786        };
2787        let perm_cfg = PermTestConfig {
2788            n_perm: 99,
2789            seed: 42,
2790            statistic: PermTestStatistic::R2,
2791        };
2792
2793        let r_signal =
2794            permutation_test_fam(&data, &y_signal, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2795        let r_null =
2796            permutation_test_fam(&data, &y_null, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2797
2798        assert!(
2799            r_signal.p_value < 0.1,
2800            "expected p < 0.1 under true effect, got p={}",
2801            r_signal.p_value
2802        );
2803        assert!(
2804            r_null.p_value > 0.1,
2805            "expected p > 0.1 under the null, got p={}",
2806            r_null.p_value
2807        );
2808    }
2809
2810    // -----------------------------------------------------------------------
2811    // history_index tests
2812    // -----------------------------------------------------------------------
2813
2814    #[test]
2815    fn history_index_synthetic_recovery() {
2816        // y_i = Σ_{u=0}^{0.5} X_i(1.0 - u) du (uniform gamma, Delta=0.5)
2817        // Discretise: y_i ≈ Σ_l X_i(T - u_l) * delta_u where T = argvals.last().
2818        // We expect R² > 0.70 and gamma approximately uniform.
2819        let n = 50;
2820        let m = 30;
2821        // argvals from 0..1
2822        let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
2823
2824        // Generate curves with amplitude variation
2825        let mut cm = vec![0.0_f64; n * m];
2826        for i in 0..n {
2827            let amp = (i as f64 + 1.0) / n as f64;
2828            for j in 0..m {
2829                let t = j as f64 / (m - 1) as f64;
2830                cm[j * n + i] = amp * (std::f64::consts::PI * 2.0 * t).sin();
2831            }
2832        }
2833        let data = FdMatrix::from_column_major(cm, n, m).unwrap();
2834
2835        // True y: integral of X_i over [T - 0.5, T] = [0.5, 1.0]
2836        // Approximate as sum of X_i at lag grid points * delta_u
2837        let window = 0.5_f64;
2838        let n_lags = 10;
2839        let delta_u = window / n_lags as f64;
2840        let big_t = argvals.last().copied().unwrap();
2841        let y: Vec<f64> = (0..n)
2842            .map(|i| {
2843                (0..n_lags)
2844                    .map(|l| {
2845                        let u_l = l as f64 * delta_u;
2846                        let t_target = big_t - u_l;
2847                        let j = argvals
2848                            .partition_point(|&v| v < t_target)
2849                            .saturating_sub(1)
2850                            .min(m - 1);
2851                        data[(i, j)] * delta_u
2852                    })
2853                    .sum::<f64>()
2854            })
2855            .collect();
2856
2857        let config = HistoryIndexConfig {
2858            window,
2859            n_lags,
2860            bandwidth: 0.0,
2861            kernel: "gaussian".to_string(),
2862        };
2863        let result = history_index(&data, &y, &argvals, &config).unwrap();
2864
2865        assert!(
2866            result.r_squared > 0.70,
2867            "expected R² > 0.70, got {}",
2868            result.r_squared
2869        );
2870        // gamma should be roughly uniform — coefficient of variation should be < 2
2871        let g_mean = result.gamma.iter().sum::<f64>() / n_lags as f64;
2872        let g_std = (result
2873            .gamma
2874            .iter()
2875            .map(|&g| (g - g_mean).powi(2))
2876            .sum::<f64>()
2877            / n_lags as f64)
2878            .sqrt();
2879        let cv = if g_mean.abs() > 1e-10 {
2880            g_std / g_mean.abs()
2881        } else {
2882            0.0
2883        };
2884        assert!(
2885            cv < 2.0,
2886            "gamma should be approximately uniform (CV < 2.0), got CV={}",
2887            cv
2888        );
2889    }
2890
2891    #[test]
2892    fn history_index_window_too_large() {
2893        let n = 20;
2894        let m = 10;
2895        let argvals = uniform_grid(m); // 0..1 range
2896        let data = make_sine_data(n, m, 1.0);
2897        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
2898
2899        // window > argvals range (which is ~1.0 for uniform_grid)
2900        let config = HistoryIndexConfig {
2901            window: 2.0,
2902            n_lags: 10,
2903            ..Default::default()
2904        };
2905        let err = history_index(&data, &y, &argvals, &config);
2906        assert!(err.is_err(), "window > argvals range should return Err");
2907        match err.unwrap_err() {
2908            FdarError::InvalidParameter { parameter, .. } => {
2909                assert_eq!(parameter, "config.window");
2910            }
2911            e => panic!("expected InvalidParameter, got {e:?}"),
2912        }
2913    }
2914
2915    #[test]
2916    fn history_index_output_shapes() {
2917        let n = 25;
2918        let m = 15;
2919        let argvals = uniform_grid(m); // range 0..1
2920        let data = make_sine_data(n, m, 1.0);
2921        let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).cos()).collect();
2922
2923        let n_lags = 12;
2924        let config = HistoryIndexConfig {
2925            window: 0.5,
2926            n_lags,
2927            ..Default::default()
2928        };
2929        let result = history_index(&data, &y, &argvals, &config).unwrap();
2930
2931        assert_eq!(
2932            result.gamma.len(),
2933            n_lags,
2934            "gamma.len() should equal n_lags"
2935        );
2936        assert_eq!(
2937            result.lag_grid.len(),
2938            n_lags,
2939            "lag_grid.len() should equal n_lags"
2940        );
2941        assert_eq!(
2942            result.fitted_values.len(),
2943            n,
2944            "fitted_values.len() should equal n"
2945        );
2946        assert_eq!(
2947            result.history_scores.len(),
2948            n,
2949            "history_scores.len() should equal n"
2950        );
2951    }
2952
2953    // -----------------------------------------------------------------------
2954    // WR-01: fregre_gkam empty-y guard
2955    // -----------------------------------------------------------------------
2956
2957    #[test]
2958    fn gkam_empty_y_returns_err() {
2959        // WR-01: fregre_gkam with n=0 (empty y) must return Err, not Ok with NaN.
2960        let m = 10;
2961        let argvals = uniform_grid(m);
2962        // Zero-row data matrix
2963        let empty_data = FdMatrix::zeros(0, m);
2964        let y_empty: Vec<f64> = vec![];
2965        let config = GkamConfig::default();
2966
2967        let result = fregre_gkam(&[&empty_data], &y_empty, &[&argvals], None, &config);
2968        assert!(
2969            result.is_err(),
2970            "fregre_gkam with empty y should return Err, got Ok"
2971        );
2972        match result.unwrap_err() {
2973            FdarError::InvalidDimension { parameter, .. } => {
2974                assert_eq!(parameter, "y", "error should report parameter='y'");
2975            }
2976            e => panic!("expected InvalidDimension(y), got {e:?}"),
2977        }
2978    }
2979
2980    // -----------------------------------------------------------------------
2981    // WR-02: FamResult / GsamResult component_fits length with scalar covariates
2982    // -----------------------------------------------------------------------
2983
2984    #[test]
2985    fn fam_scalar_covariates_component_fits_len() {
2986        // WR-02: when scalar_covariates is provided, component_fits and bandwidths
2987        // should have length ncomp + p_scalar, not ncomp alone.
2988        let n = 30;
2989        let m = 12;
2990        let argvals = uniform_grid(m);
2991        let data = make_sine_data(n, m, 1.0);
2992        let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).cos()).collect();
2993
2994        // Build a scalar covariate matrix (n × 2)
2995        let p_scalar = 2_usize;
2996        let sc_vals: Vec<f64> = (0..n * p_scalar).map(|k| (k as f64 * 0.1).sin()).collect();
2997        // FdMatrix is column-major: n rows, p_scalar cols
2998        let mut sc_cm = vec![0.0_f64; n * p_scalar];
2999        for row in 0..n {
3000            for col in 0..p_scalar {
3001                sc_cm[col * n + row] = sc_vals[row * p_scalar + col];
3002            }
3003        }
3004        let sc = FdMatrix::from_column_major(sc_cm, n, p_scalar).unwrap();
3005
3006        let ncomp = 2;
3007        let config = FamConfig {
3008            ncomp,
3009            ..Default::default()
3010        };
3011        let result = fam(&data, &y, &argvals, Some(&sc), &config).unwrap();
3012
3013        let expected_len = ncomp + p_scalar;
3014        assert_eq!(
3015            result.component_fits.len(),
3016            expected_len,
3017            "component_fits.len() should be ncomp + p_scalar = {expected_len}, got {}",
3018            result.component_fits.len()
3019        );
3020        assert_eq!(
3021            result.bandwidths.len(),
3022            expected_len,
3023            "bandwidths.len() should be ncomp + p_scalar = {expected_len}, got {}",
3024            result.bandwidths.len()
3025        );
3026    }
3027
3028    #[test]
3029    fn gsam_scalar_covariates_component_fits_len() {
3030        // WR-02: same check for fregre_gsam.
3031        let n = 30;
3032        let m = 12;
3033        let argvals = uniform_grid(m);
3034        let data = make_sine_data(n, m, 1.0);
3035        let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.3).sin()).collect();
3036
3037        let p_scalar = 2_usize;
3038        let mut sc_cm = vec![0.0_f64; n * p_scalar];
3039        for row in 0..n {
3040            for col in 0..p_scalar {
3041                sc_cm[col * n + row] = ((row * p_scalar + col) as f64 * 0.15).cos();
3042            }
3043        }
3044        let sc = FdMatrix::from_column_major(sc_cm, n, p_scalar).unwrap();
3045
3046        let ncomp = 2;
3047        let config = GsamConfig {
3048            ncomp,
3049            ..Default::default()
3050        };
3051        let result = fregre_gsam(&data, &y, &argvals, Some(&sc), &config).unwrap();
3052
3053        let expected_len = ncomp + p_scalar;
3054        assert_eq!(
3055            result.component_fits.len(),
3056            expected_len,
3057            "component_fits.len() should be ncomp + p_scalar = {expected_len}, got {}",
3058            result.component_fits.len()
3059        );
3060        assert_eq!(
3061            result.bandwidths.len(),
3062            expected_len,
3063            "bandwidths.len() should be ncomp + p_scalar = {expected_len}, got {}",
3064            result.bandwidths.len()
3065        );
3066    }
3067
3068    // -----------------------------------------------------------------------
3069    // WR-04: permutation_test_fam n_perm == 0 guard
3070    // -----------------------------------------------------------------------
3071
3072    #[test]
3073    fn perm_zero_nperm_returns_err() {
3074        // WR-04: n_perm == 0 must return Err rather than p_value = 1.0.
3075        let n = 20;
3076        let m = 8;
3077        let argvals = uniform_grid(m);
3078        let data = make_sine_data(n, m, 1.0);
3079        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
3080        let fam_cfg = FamConfig {
3081            ncomp: 1,
3082            ..Default::default()
3083        };
3084        let perm_cfg = PermTestConfig {
3085            n_perm: 0,
3086            seed: 42,
3087            statistic: PermTestStatistic::R2,
3088        };
3089        let result = permutation_test_fam(&data, &y, &argvals, None, &fam_cfg, &perm_cfg);
3090        assert!(result.is_err(), "n_perm=0 should return Err");
3091        match result.unwrap_err() {
3092            FdarError::InvalidParameter { parameter, .. } => {
3093                assert_eq!(parameter, "perm_config.n_perm");
3094            }
3095            e => panic!("expected InvalidParameter, got {e:?}"),
3096        }
3097    }
3098}