Skip to main content

fdars_core/inference/
itp.rs

1//! Interval Testing Procedure (ITP) family for functional hypothesis testing.
2//!
3//! Implements the ITP as defined in the CRAN `fdatest` 2.1.1 package
4//! (`ITP1bspline`, `ITP2bspline`, `ITPlmbspline`), matching the algorithm of
5//! Pini & Vantini (2016, Biometrics). The ITP projects functional observations
6//! onto a finite basis, runs per-component univariate permutation tests, and
7//! applies an interval-wise closure adjustment so that the adjusted p-value for
8//! basis component `k` equals the maximum joint permutation p-value over all
9//! contiguous intervals containing `k`.
10//!
11//! # References
12//!
13//! * Pini, A. & Vantini, S. (2016). Interval-wise testing for functional data.
14//!   *Biometrics*, 73(3), 835–845. <https://doi.org/10.1111/biom.12679>
15//! * CRAN `fdatest` 2.1.1 — <https://cran.r-project.org/package=fdatest>
16
17use crate::basis::projection::{fdata_to_basis, ProjectionBasisType};
18use crate::error::FdarError;
19use crate::iter_maybe_parallel;
20use crate::matrix::FdMatrix;
21use rand::rngs::StdRng;
22use rand::SeedableRng;
23
24#[cfg(feature = "parallel")]
25use rayon::iter::ParallelIterator;
26
27// ─────────────────────────────────────────────────────────────────────────────
28// Public result type
29// ─────────────────────────────────────────────────────────────────────────────
30
31/// Result of an Interval Testing Procedure (ITP) family test.
32///
33/// Provides per-basis-component raw and adjusted p-values. The adjusted
34/// p-values implement the interval-wise closure adjustment (Pini & Vantini,
35/// Biometrics 2016): `adjusted_pvalues[k]` is the maximum over all contiguous
36/// intervals `[a, b]` containing `k` of the joint permutation p-value for that
37/// interval. A small adjusted p-value at component `k` indicates that the
38/// domain sub-interval represented by basis function `k` contributes to the
39/// rejection of H₀.
40///
41/// **Note on `n_basis`:** For B-spline bases, the actual number of basis
42/// functions used may differ from the requested `nbasis` due to knot clamping.
43/// Always read `n_basis` from `ItpResult` rather than the argument passed to
44/// the entry point.
45#[derive(Debug, Clone, PartialEq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[non_exhaustive]
48pub struct ItpResult {
49    /// Adjusted (interval-wise closed) p-values, one per basis component.
50    pub adjusted_pvalues: Vec<f64>,
51    /// Raw (point-wise) permutation p-values, one per basis component.
52    /// Uses the `(n_ge + 1) / (n_perm + 1)` correction (avoids zero p-values;
53    /// deliberate divergence from the R `fdatest` convention of `n_ge / B`).
54    pub raw_pvalues: Vec<f64>,
55    /// Basis type used for projection.
56    pub basis_type: ProjectionBasisType,
57    /// Number of basis functions actually used (may differ from requested for
58    /// B-splines due to knot clamping).
59    pub n_basis: usize,
60    /// Number of permutations used.
61    pub n_perm: usize,
62}
63
64// ─────────────────────────────────────────────────────────────────────────────
65// Private closure-adjustment helpers
66// ─────────────────────────────────────────────────────────────────────────────
67
68/// Rank-transform the permutation statistic matrix to pseudo-p-values.
69///
70/// For each component `k`, ranks all `b` permutation statistics in descending
71/// order. The rank-based pseudo-p for permutation `i` at component `k` is
72/// `rank_desc / b`, so the largest stat gets `1/b` (smallest pseudo-p) and the
73/// smallest stat gets `b/b = 1.0` (largest pseudo-p).
74///
75/// Returns `L` of shape `(b, p)`.
76fn rank_transform(t_perm: &[Vec<f64>], p: usize, b: usize) -> Vec<Vec<f64>> {
77    let mut l = vec![vec![0.0f64; p]; b];
78    let assignments: Vec<Vec<(usize, f64)>> = iter_maybe_parallel!(0..p)
79        .map(|k| {
80            let mut col: Vec<(f64, usize)> = (0..b).map(|i| (t_perm[i][k], i)).collect();
81            // Sort descending: largest stat → rank 1 → smallest pseudo-p (1/b)
82            col.sort_unstable_by(|a, b_| {
83                b_.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)
84            });
85            col.iter()
86                .enumerate()
87                .map(|(rank_zero_based, &(_, orig_idx))| {
88                    (orig_idx, (rank_zero_based + 1) as f64 / b as f64)
89                })
90                .collect::<Vec<_>>()
91        })
92        .collect();
93
94    for (k, col_assignments) in assignments.iter().enumerate() {
95        for &(orig_idx, pseudo_p) in col_assignments {
96            l[orig_idx][k] = pseudo_p;
97        }
98    }
99    l
100}
101
102/// Fisher's combining function: `-2 * Σ log(max(v, 1e-300))`.
103///
104/// Clamps each p-value to at least `1e-300` before taking the log, preventing
105/// `-inf` / NaN when a raw p-value is exactly 0.0 (T-30-02).
106#[inline]
107fn fisher_cf(vals: &[f64]) -> f64 {
108    -2.0 * vals.iter().map(|&v| v.max(1e-300).ln()).sum::<f64>()
109}
110
111/// Build the asymmetric interval p-value matrix via the O(p²) interval loop.
112///
113/// `pval_matrix[row][col]` holds the joint permutation p-value for the
114/// contiguous interval whose length is `p - row`, starting at column `col`.
115///
116/// * Row `p-1` (R's row `p`): raw per-component p-values (length-1 intervals).
117/// * Row `row_idx = p - interval_len` (R's row `i = p - interval_len`): joint
118///   p-values for all length-`interval_len` contiguous intervals.
119///
120/// The circular "wrap-around" is implemented by doubling both the raw p-value
121/// vector and the `L` matrix (circular trick from the R source).
122///
123/// **Raw p-value divergence:** `pval_matrix` for interval rows uses
124/// `n_ge / n_perm` (no +1 correction), matching the R source for the internal
125/// closure matrix. Only the top-level `raw_pvalues` field in `ItpResult` uses
126/// the `(n_ge + 1) / (n_perm + 1)` correction.
127fn build_pval_matrix(
128    raw_pvalues: &[f64],
129    l: &[Vec<f64>],
130    p: usize,
131    n_perm: usize,
132) -> Vec<Vec<f64>> {
133    let mut mat = vec![vec![1.0f64; p]; p];
134
135    // Last row: raw p-values (length-1 intervals)
136    mat[p - 1][..p].copy_from_slice(&raw_pvalues[..p]);
137
138    // Doubled arrays for the circular wrap-around
139    let pval_2x: Vec<f64> = raw_pvalues
140        .iter()
141        .chain(raw_pvalues.iter())
142        .copied()
143        .collect();
144    let l_2x: Vec<Vec<f64>> = l
145        .iter()
146        .map(|row| row.iter().chain(row.iter()).copied().collect())
147        .collect();
148
149    // interval_len = 2..=p  (R's i from p-1 down to 1)
150    for interval_len in 2..=p {
151        let row_idx = p - interval_len; // R's row i = p - interval_len
152        for j in 0..p {
153            let inf = j; // 0-indexed start in the 2x array
154            let sup = j + interval_len; // exclusive end
155            let t0_temp = fisher_cf(&pval_2x[inf..sup]);
156            let n_ge = l_2x
157                .iter()
158                .filter(|perm_row| fisher_cf(&perm_row[inf..sup]) >= t0_temp)
159                .count();
160            mat[row_idx][j] = n_ge as f64 / n_perm as f64;
161        }
162    }
163    mat
164}
165
166/// Compute interval-wise closure-adjusted p-values.
167///
168/// For each basis component `k`, the adjusted p-value is the maximum over all
169/// contiguous intervals `[a, b]` (where `a ≤ k ≤ b`) of the joint p-value for
170/// that interval. This implements the `pval.correct` function from the CRAN
171/// `fdatest` package (Pini & Vantini 2016 closure), matching
172/// `fdatest::ITP1bspline`'s `pval.correct`.
173///
174/// **Implementation note:** The R source doubles and reverses the column-doubled
175/// matrix (`matrice_pval_2_2x <- matrice_pval_2_2x[, (2*p):1]`) before the
176/// cone walk, then reverses the output (`corrected.pval[p:1]`). The reversal
177/// restores natural component order (component 0, 1, …, p-1 matching the
178/// coefficient matrix columns).
179///
180/// **Raw-p divergence:** The `(n_ge + 1)/(n_perm + 1)` correction used in
181/// `itp_one_pop`'s `raw_pvalues` avoids zero p-values and is a deliberate
182/// deviation from R's `/B` convention (RESEARCH Assumption A4).
183fn pval_correct(pval_matrix: &[Vec<f64>], p: usize) -> Vec<f64> {
184    // `get_2x_rev(row, col)`: column `col` in the doubled+reversed (2p-wide)
185    // matrix maps to original column `(2*p - 1 - col) % p`.
186    //
187    // Equivalent to R's:
188    //   matrice_pval_2_2x <- cbind(pval.matrix, pval.matrix)
189    //   matrice_pval_2_2x <- matrice_pval_2_2x[, (2*p):1]   # reverse columns
190    let get_2x_rev = |row: usize, col: usize| -> f64 {
191        let orig_col = (2 * p - 1).saturating_sub(col) % p;
192        pval_matrix[row][orig_col]
193    };
194
195    let mut corrected = vec![0.0f64; p];
196    for var in 0..p {
197        // R: pval_var <- matrice_pval_2_2x[p, var]  (1-indexed row p = our row p-1)
198        let mut pval_var = get_2x_rev(p - 1, var);
199        let mut fine = var;
200        // R: for riga in (p-1):1 → our riga_idx = p-2 down to 0
201        for riga_idx in (0..p - 1).rev() {
202            fine += 1;
203            // R: pval_cono <- matrice_pval_2_2x[riga, inizio:fine]
204            for col in var..=fine {
205                let v = get_2x_rev(riga_idx, col);
206                if v > pval_var {
207                    pval_var = v;
208                }
209            }
210        }
211        corrected[var] = pval_var;
212    }
213    // R: corrected.pval <- corrected.pval[p:1]  (reverse to natural component order)
214    corrected.reverse();
215    corrected
216}
217
218// ─────────────────────────────────────────────────────────────────────────────
219// One-population entry point helpers
220// ─────────────────────────────────────────────────────────────────────────────
221
222/// Subtract `mu0` elementwise from each row of `data` if `Some`; otherwise
223/// return a reference-compatible clone. Returns `Err` if `mu0.len() != m`.
224fn center_one_pop(data: &FdMatrix, mu0: Option<&[f64]>) -> Result<FdMatrix, FdarError> {
225    let (n, m) = data.shape();
226    match mu0 {
227        None => Ok(data.clone()),
228        Some(mu) => {
229            if mu.len() != m {
230                return Err(FdarError::InvalidDimension {
231                    parameter: "mu0",
232                    expected: format!("{m} elements (matching data columns)"),
233                    actual: format!("{} elements", mu.len()),
234                });
235            }
236            let mut centered = FdMatrix::zeros(n, m);
237            for j in 0..m {
238                for i in 0..n {
239                    centered[(i, j)] = data[(i, j)] - mu[j];
240                }
241            }
242            Ok(centered)
243        }
244    }
245}
246
247// ─────────────────────────────────────────────────────────────────────────────
248// Public entry point: one-population ITP
249// ─────────────────────────────────────────────────────────────────────────────
250
251/// Interval-wise one-population test (sign-flip permutation).
252///
253/// Tests H₀: the mean function equals `mu0` (or zero if `None`). Projects the
254/// (possibly centred) functional data onto `nbasis` basis functions, then runs
255/// an interval-wise closure test on the basis coefficients.
256///
257/// The test statistic per basis component `k` is `|colMean(coeff[:, k])|`.
258/// The permutation null is sign-flip: each curve's coefficient row is
259/// multiplied by an i.i.d. ±1 Bernoulli draw.
260///
261/// Matches `fdatest::ITP1bspline` up to the `(n_ge + 1) / (n_perm + 1)`
262/// p-value correction (R uses `n_ge / B` without the +1).
263///
264/// # Arguments
265///
266/// * `data` — functional data matrix, shape `(n, m)` (column-major).
267/// * `argvals` — evaluation points, length `m`.
268/// * `mu0` — null mean function, length `m`; `None` = zero mean.
269/// * `basis_type` — `ProjectionBasisType::Bspline` or `::Fourier`.
270/// * `nbasis` — requested number of basis functions (≥ 2). For B-splines the
271///   actual count may be lower due to knot clamping; use `result.n_basis`.
272/// * `n_perm` — number of sign-flip permutations (≥ 1).
273/// * `seed` — RNG seed for reproducibility.
274///
275/// # Errors
276///
277/// * `InvalidDimension` — if `n < 2`, `argvals.len() != m`, or `mu0.len() != m`.
278/// * `InvalidParameter` — if `nbasis < 2`, `n_perm == 0`, or basis projection fails.
279#[must_use = "the ItpResult contains the adjusted p-values"]
280pub fn itp_one_pop(
281    data: &FdMatrix,
282    argvals: &[f64],
283    mu0: Option<&[f64]>,
284    basis_type: ProjectionBasisType,
285    nbasis: usize,
286    n_perm: usize,
287    seed: u64,
288) -> Result<ItpResult, FdarError> {
289    // 1. Validate inputs
290    let (n, m) = data.shape();
291    if n < 2 {
292        return Err(FdarError::InvalidDimension {
293            parameter: "data",
294            expected: "at least 2 rows (observations)".to_string(),
295            actual: format!("{n} rows"),
296        });
297    }
298    if argvals.len() != m {
299        return Err(FdarError::InvalidDimension {
300            parameter: "argvals",
301            expected: format!("{m} elements (matching data columns)"),
302            actual: format!("{} elements", argvals.len()),
303        });
304    }
305    if nbasis < 2 {
306        return Err(FdarError::InvalidParameter {
307            parameter: "nbasis",
308            message: "must be >= 2".to_string(),
309        });
310    }
311    if n_perm == 0 {
312        return Err(FdarError::InvalidParameter {
313            parameter: "n_perm",
314            message: "must be >= 1".to_string(),
315        });
316    }
317
318    // 2. Subtract mu0 (if provided), then project to basis coefficients
319    let centered = center_one_pop(data, mu0)?;
320    let proj = fdata_to_basis(&centered, argvals, nbasis, basis_type).ok_or_else(|| {
321        FdarError::InvalidParameter {
322            parameter: "nbasis",
323            message: format!("basis projection failed (nbasis={nbasis}, m={m})"),
324        }
325    })?;
326    let coeff = proj.coefficients; // FdMatrix shape (n, p)
327    let p = proj.n_basis; // actual basis count (may differ from nbasis for B-spline)
328
329    // 3. Observed per-component statistic: |colMean(coeff[:, k])|
330    let t0: Vec<f64> = (0..p)
331        .map(|k| {
332            let mean_k = (0..n).map(|i| coeff[(i, k)]).sum::<f64>() / n as f64;
333            mean_k.abs()
334        })
335        .collect();
336
337    // 4. Sign-flip permutation loop → t_perm (n_perm, p)
338    // Single sequential loop: one RNG drives all permutations in order.
339    let mut rng = StdRng::seed_from_u64(seed);
340    let mut t_perm: Vec<Vec<f64>> = Vec::with_capacity(n_perm);
341    for _ in 0..n_perm {
342        use rand::Rng;
343        let signs: Vec<f64> = (0..n)
344            .map(|_| if rng.gen::<bool>() { 1.0 } else { -1.0 })
345            .collect();
346        let row: Vec<f64> = (0..p)
347            .map(|k| {
348                let mean_k = (0..n).map(|i| coeff[(i, k)] * signs[i]).sum::<f64>() / n as f64;
349                mean_k.abs()
350            })
351            .collect();
352        t_perm.push(row);
353    }
354
355    // 5. Raw per-component p-values (INF-01 convention: +1 correction)
356    let raw_pvalues: Vec<f64> = (0..p)
357        .map(|k| {
358            let n_ge = t_perm.iter().filter(|row| row[k] >= t0[k]).count();
359            (n_ge as f64 + 1.0) / (n_perm as f64 + 1.0)
360        })
361        .collect();
362
363    // 6. Rank-transform → L matrix (n_perm, p)
364    let l = rank_transform(&t_perm, p, n_perm);
365
366    // 7. Build O(p²) interval p-value matrix
367    let pval_matrix = build_pval_matrix(&raw_pvalues, &l, p, n_perm);
368
369    // 8. Closure max-adjustment
370    let adjusted_pvalues = pval_correct(&pval_matrix, p);
371
372    Ok(ItpResult {
373        adjusted_pvalues,
374        raw_pvalues,
375        basis_type,
376        n_basis: p,
377        n_perm,
378    })
379}
380
381// ─────────────────────────────────────────────────────────────────────────────
382// Two-population entry point helpers
383// ─────────────────────────────────────────────────────────────────────────────
384
385/// Validate two-population inputs; return `(n_a, n_b, m)` or `FdarError`.
386fn validate_two_samples_itp(
387    data_a: &FdMatrix,
388    data_b: &FdMatrix,
389    argvals: &[f64],
390) -> Result<(usize, usize, usize), FdarError> {
391    let (n_a, m_a) = data_a.shape();
392    let (n_b, m_b) = data_b.shape();
393    if m_a == 0 || m_b == 0 {
394        return Err(FdarError::InvalidDimension {
395            parameter: "data",
396            expected: "at least 1 column (grid points)".to_string(),
397            actual: format!("data_a has {m_a} columns, data_b has {m_b} columns"),
398        });
399    }
400    if m_a != m_b {
401        return Err(FdarError::InvalidDimension {
402            parameter: "data_b",
403            expected: format!("{m_a} columns (matching data_a)"),
404            actual: format!("{m_b} columns"),
405        });
406    }
407    if argvals.len() != m_a {
408        return Err(FdarError::InvalidDimension {
409            parameter: "argvals",
410            expected: format!("{m_a} elements (matching data columns)"),
411            actual: format!("{} elements", argvals.len()),
412        });
413    }
414    if n_a < 2 || n_b < 2 {
415        return Err(FdarError::InvalidDimension {
416            parameter: "data",
417            expected: "at least 2 rows per sample".to_string(),
418            actual: format!("data_a has {n_a} rows, data_b has {n_b} rows"),
419        });
420    }
421    Ok((n_a, n_b, m_a))
422}
423
424/// Pool two coefficient matrices (shape `(n_a, p)` and `(n_b, p)`) into one
425/// `(n_a + n_b, p)` matrix. Rows 0..n_a come from `coeff_a`, rows n_a.. from `coeff_b`.
426fn pool_coefficients_itp(
427    coeff_a: &FdMatrix,
428    coeff_b: &FdMatrix,
429    n_a: usize,
430    n_b: usize,
431    p: usize,
432) -> FdMatrix {
433    let mut pooled = FdMatrix::zeros(n_a + n_b, p);
434    for k in 0..p {
435        for i in 0..n_a {
436            pooled[(i, k)] = coeff_a[(i, k)];
437        }
438        for i in 0..n_b {
439            pooled[(n_a + i, k)] = coeff_b[(i, k)];
440        }
441    }
442    pooled
443}
444
445/// Fisher–Yates in-place shuffle of an index vector (7-line copy of
446/// `permutation::shuffle_labels`, which is private to that module).
447fn shuffle_itp(v: &mut [usize], rng: &mut StdRng) {
448    use rand::Rng;
449    let n = v.len();
450    for i in (1..n).rev() {
451        let j = rng.gen_range(0..=i);
452        v.swap(i, j);
453    }
454}
455
456// ─────────────────────────────────────────────────────────────────────────────
457// Public entry point: two-population ITP
458// ─────────────────────────────────────────────────────────────────────────────
459
460/// Interval-wise two-population test (pool + relabel permutation).
461///
462/// Tests H₀: the mean functions of groups A and B are equal. Projects both
463/// groups onto `nbasis` basis functions, pools the resulting coefficient
464/// matrices `(n_a + n_b, p)`, and runs an interval-wise closure test on the
465/// per-component mean-difference statistic.
466///
467/// The test statistic per basis component `k` is
468/// `|colMean(coeff_a[:, k]) - colMean(coeff_b[:, k])|`.  The permutation null
469/// relabels the pooled coefficient rows via Fisher–Yates (inline copy of the
470/// `permutation::shuffle_labels` pattern).
471///
472/// Matches `fdatest::ITP2bspline` up to the `(n_ge + 1) / (n_perm + 1)`
473/// p-value correction (R uses `n_ge / B` without the +1).
474///
475/// # Arguments
476///
477/// * `data_a` — functional data matrix for group A, shape `(n_a, m)`.
478/// * `data_b` — functional data matrix for group B, shape `(n_b, m)`.
479/// * `argvals` — evaluation points, length `m`.
480/// * `basis_type` — `ProjectionBasisType::Bspline` or `::Fourier`.
481/// * `nbasis` — requested number of basis functions (≥ 2).
482/// * `n_perm` — number of relabel permutations (≥ 1).
483/// * `seed` — RNG seed for reproducibility.
484///
485/// # Errors
486///
487/// * `InvalidDimension` — if `n_a < 2 || n_b < 2`, `m_a != m_b`, or `argvals.len() != m`.
488/// * `InvalidParameter` — if `nbasis < 2`, `n_perm == 0`, or basis projection fails.
489#[must_use = "the ItpResult contains the adjusted p-values"]
490pub fn itp_two_pop(
491    data_a: &FdMatrix,
492    data_b: &FdMatrix,
493    argvals: &[f64],
494    basis_type: ProjectionBasisType,
495    nbasis: usize,
496    n_perm: usize,
497    seed: u64,
498) -> Result<ItpResult, FdarError> {
499    // 1. Validate inputs
500    let (n_a, n_b, m) = validate_two_samples_itp(data_a, data_b, argvals)?;
501    if nbasis < 2 {
502        return Err(FdarError::InvalidParameter {
503            parameter: "nbasis",
504            message: "must be >= 2".to_string(),
505        });
506    }
507    if n_perm == 0 {
508        return Err(FdarError::InvalidParameter {
509            parameter: "n_perm",
510            message: "must be >= 1".to_string(),
511        });
512    }
513
514    // 2. Project each group to basis coefficients
515    let proj_a = fdata_to_basis(data_a, argvals, nbasis, basis_type).ok_or_else(|| {
516        FdarError::InvalidParameter {
517            parameter: "nbasis",
518            message: format!("basis projection failed for data_a (nbasis={nbasis}, m={m})"),
519        }
520    })?;
521    let proj_b = fdata_to_basis(data_b, argvals, nbasis, basis_type).ok_or_else(|| {
522        FdarError::InvalidParameter {
523            parameter: "nbasis",
524            message: format!("basis projection failed for data_b (nbasis={nbasis}, m={m})"),
525        }
526    })?;
527    let p = proj_a.n_basis; // actual basis count (clamp-safe)
528    let coeff_a = proj_a.coefficients; // (n_a, p)
529    let coeff_b = proj_b.coefficients; // (n_b, p)
530
531    // 3. Pool coefficient rows into (n, p)
532    let pooled = pool_coefficients_itp(&coeff_a, &coeff_b, n_a, n_b, p);
533    let n = n_a + n_b;
534
535    // 4. Observed per-component statistic: |colMean(a) - colMean(b)|
536    let t0: Vec<f64> = (0..p)
537        .map(|k| {
538            let m_a = (0..n_a).map(|i| pooled[(i, k)]).sum::<f64>() / n_a as f64;
539            let m_b = (n_a..n).map(|i| pooled[(i, k)]).sum::<f64>() / n_b as f64;
540            (m_a - m_b).abs()
541        })
542        .collect();
543
544    // 5. Pool + relabel permutation loop → t_perm (n_perm, p)
545    let mut rng = StdRng::seed_from_u64(seed);
546    let mut perm_idx: Vec<usize> = (0..n).collect();
547    let mut t_perm: Vec<Vec<f64>> = Vec::with_capacity(n_perm);
548    for _ in 0..n_perm {
549        shuffle_itp(&mut perm_idx, &mut rng);
550        let row: Vec<f64> = (0..p)
551            .map(|k| {
552                let m_a = (0..n_a).map(|r| pooled[(r, k)]).sum::<f64>() / n_a as f64;
553                let m_b = (n_a..n).map(|i| pooled[(perm_idx[i], k)]).sum::<f64>() / n_b as f64;
554                (m_a - m_b).abs()
555            })
556            .collect();
557        t_perm.push(row);
558    }
559
560    // 6. Raw per-component p-values (+1 correction)
561    let raw_pvalues: Vec<f64> = (0..p)
562        .map(|k| {
563            let n_ge = t_perm.iter().filter(|row| row[k] >= t0[k]).count();
564            (n_ge as f64 + 1.0) / (n_perm as f64 + 1.0)
565        })
566        .collect();
567
568    // 7–9. Rank-transform → pval_matrix → closure adjustment
569    let l = rank_transform(&t_perm, p, n_perm);
570    let pval_matrix = build_pval_matrix(&raw_pvalues, &l, p, n_perm);
571    let adjusted_pvalues = pval_correct(&pval_matrix, p);
572
573    Ok(ItpResult {
574        adjusted_pvalues,
575        raw_pvalues,
576        basis_type,
577        n_basis: p,
578        n_perm,
579    })
580}
581
582// ─────────────────────────────────────────────────────────────────────────────
583// FLM entry point helpers
584// ─────────────────────────────────────────────────────────────────────────────
585
586/// Simple-regression t-statistic `|β̂_k / se_k|` for basis component `k`.
587///
588/// Fits OLS regression of `y` on the k-th column of `coeff`. Returns `0.0`
589/// if the predictor is degenerate (`sxx < 1e-30`) or the residual variance
590/// is non-positive (`se2 <= 0.0`), preventing divide-by-zero and NaN
591/// propagation (T-30-04 guard).
592fn component_t_stat(y: &[f64], coeff: &FdMatrix, k: usize) -> f64 {
593    let n = y.len();
594    let mx: f64 = (0..n).map(|i| coeff[(i, k)]).sum::<f64>() / n as f64;
595    let my: f64 = y.iter().sum::<f64>() / n as f64;
596    let sxx: f64 = (0..n).map(|i| (coeff[(i, k)] - mx).powi(2)).sum();
597    if sxx < 1e-30 {
598        return 0.0;
599    }
600    let sxy: f64 = (0..n).map(|i| (coeff[(i, k)] - mx) * (y[i] - my)).sum();
601    let beta = sxy / sxx;
602    let rss: f64 = (0..n)
603        .map(|i| {
604            let yhat = my + beta * (coeff[(i, k)] - mx);
605            (y[i] - yhat).powi(2)
606        })
607        .sum();
608    // se2 = rss / ((n-2) * sxx)  [standard error of beta squared]
609    let se2 = rss / ((n - 2) as f64 * sxx);
610    if se2 <= 0.0 {
611        return 0.0;
612    }
613    (beta / se2.sqrt()).abs()
614}
615
616// ─────────────────────────────────────────────────────────────────────────────
617// Public entry point: interval-wise FLM coefficient test
618// ─────────────────────────────────────────────────────────────────────────────
619
620/// Interval-wise FLM coefficient test (response-permutation null).
621///
622/// Tests H₀: the response `y` is independent of the functional predictor
623/// `data`. Projects `data` onto `nbasis` basis functions and, for each
624/// basis component `k`, computes a simple-regression t-statistic
625/// `|β̂_k / se_k|`. The permutation null shuffles the response vector `y`
626/// (response permutation) and re-evaluates all per-component t-statistics.
627///
628/// **Assumption A2 divergence from R:** This implementation uses the
629/// response-permutation simplification (shuffle `y`) rather than the
630/// partial-residual method employed by `fdatest::ITPlmbspline`. The simpler
631/// approach tests the global null "y is independent of the functional
632/// predictor" consistently with the INF-01 permutation philosophy.
633/// Per-component partial-residual permutation would require fitting
634/// `n_perm × p` additional regressions and is not implemented here.
635///
636/// # Arguments
637///
638/// * `data` — functional data matrix (predictor), shape `(n, m)`.
639/// * `y` — response vector, length `n`.
640/// * `argvals` — evaluation points, length `m`.
641/// * `basis_type` — `ProjectionBasisType::Bspline` or `::Fourier`.
642/// * `nbasis` — requested number of basis functions (≥ 2).
643/// * `n_perm` — number of response permutations (≥ 1).
644/// * `seed` — RNG seed for reproducibility.
645///
646/// # Errors
647///
648/// * `InvalidDimension` — if `n < 2`, `y.len() != n`, or `argvals.len() != m`.
649/// * `InvalidParameter` — if `nbasis < 2`, `n_perm == 0`, or basis projection fails.
650#[must_use = "the ItpResult contains the adjusted p-values"]
651pub fn itp_flm(
652    data: &FdMatrix,
653    y: &[f64],
654    argvals: &[f64],
655    basis_type: ProjectionBasisType,
656    nbasis: usize,
657    n_perm: usize,
658    seed: u64,
659) -> Result<ItpResult, FdarError> {
660    // 1. Validate inputs
661    let (n, m) = data.shape();
662    if n < 2 {
663        return Err(FdarError::InvalidDimension {
664            parameter: "data",
665            expected: "at least 2 rows (observations)".to_string(),
666            actual: format!("{n} rows"),
667        });
668    }
669    if y.len() != n {
670        return Err(FdarError::InvalidDimension {
671            parameter: "y",
672            expected: format!("{n} elements (matching data rows)"),
673            actual: format!("{} elements", y.len()),
674        });
675    }
676    if argvals.len() != m {
677        return Err(FdarError::InvalidDimension {
678            parameter: "argvals",
679            expected: format!("{m} elements (matching data columns)"),
680            actual: format!("{} elements", argvals.len()),
681        });
682    }
683    if nbasis < 2 {
684        return Err(FdarError::InvalidParameter {
685            parameter: "nbasis",
686            message: "must be >= 2".to_string(),
687        });
688    }
689    if n_perm == 0 {
690        return Err(FdarError::InvalidParameter {
691            parameter: "n_perm",
692            message: "must be >= 1".to_string(),
693        });
694    }
695
696    // 2. Project X onto basis coefficients (once)
697    let proj = fdata_to_basis(data, argvals, nbasis, basis_type).ok_or_else(|| {
698        FdarError::InvalidParameter {
699            parameter: "nbasis",
700            message: format!("basis projection failed (nbasis={nbasis}, m={m})"),
701        }
702    })?;
703    let coeff = proj.coefficients; // (n, p)
704    let p = proj.n_basis;
705
706    // 3. Observed per-component t-statistics
707    let t0: Vec<f64> = (0..p).map(|k| component_t_stat(y, &coeff, k)).collect();
708
709    // 4. Response-permutation loop → t_perm (n_perm, p)
710    let mut rng = StdRng::seed_from_u64(seed);
711    let mut perm_idx: Vec<usize> = (0..n).collect();
712    let mut t_perm: Vec<Vec<f64>> = Vec::with_capacity(n_perm);
713    let mut y_perm: Vec<f64> = vec![0.0; n];
714    for _ in 0..n_perm {
715        shuffle_itp(&mut perm_idx, &mut rng);
716        for i in 0..n {
717            y_perm[i] = y[perm_idx[i]];
718        }
719        let row: Vec<f64> = (0..p)
720            .map(|k| component_t_stat(&y_perm, &coeff, k))
721            .collect();
722        t_perm.push(row);
723    }
724
725    // 5. Raw per-component p-values (+1 correction)
726    let raw_pvalues: Vec<f64> = (0..p)
727        .map(|k| {
728            let n_ge = t_perm.iter().filter(|row| row[k] >= t0[k]).count();
729            (n_ge as f64 + 1.0) / (n_perm as f64 + 1.0)
730        })
731        .collect();
732
733    // 6–8. Rank-transform → pval_matrix → closure adjustment
734    let l = rank_transform(&t_perm, p, n_perm);
735    let pval_matrix = build_pval_matrix(&raw_pvalues, &l, p, n_perm);
736    let adjusted_pvalues = pval_correct(&pval_matrix, p);
737
738    Ok(ItpResult {
739        adjusted_pvalues,
740        raw_pvalues,
741        basis_type,
742        n_basis: p,
743        n_perm,
744    })
745}
746
747// ─────────────────────────────────────────────────────────────────────────────
748// Tests
749// ─────────────────────────────────────────────────────────────────────────────
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use crate::test_helpers::uniform_grid;
755
756    // ─── pval_correct hand-computed unit test ────────────────────────────────
757
758    /// Verifies the closure-adjustment index math against a hand-traced
759    /// execution for p = 4.
760    ///
761    /// The `pval_matrix` was chosen with strictly decreasing values along rows
762    /// (larger intervals have lower joint p-values) to make the cone-walk
763    /// outcome easy to compute by inspection. Expected values were derived by
764    /// tracing `get_2x_rev` and the cone-walk loop by hand.
765    ///
766    /// With this matrix:
767    /// ```text
768    /// row 0 (len-4):  [0.30, 0.25, 0.20, 0.15]
769    /// row 1 (len-3):  [0.40, 0.35, 0.28, 0.22]
770    /// row 2 (len-2):  [0.50, 0.45, 0.38, 0.32]
771    /// row 3 (len-1):  [0.60, 0.55, 0.48, 0.42]
772    /// ```
773    ///
774    /// Hand-traced result (before reverse): [0.42, 0.48, 0.55, 0.60]
775    /// After `.reverse()`:                  [0.60, 0.55, 0.48, 0.42]
776    #[test]
777    fn pval_correct_hand_computed() {
778        let p = 4;
779        // pval_matrix[row][col]
780        // row 0: full interval (length p)
781        // row p-1: raw per-component (length 1)
782        let pval_matrix = vec![
783            vec![0.30, 0.25, 0.20, 0.15], // row 0: length-4 interval
784            vec![0.40, 0.35, 0.28, 0.22], // row 1: length-3 intervals
785            vec![0.50, 0.45, 0.38, 0.32], // row 2: length-2 intervals
786            vec![0.60, 0.55, 0.48, 0.42], // row 3: raw p-values (length-1)
787        ];
788
789        let adjusted = pval_correct(&pval_matrix, p);
790
791        // Expected values derived by hand (see docstring above):
792        // get_2x_rev maps col c → original col (2*4-1-c) % 4 = (7-c) % 4
793        // var=0: start get_2x_rev(3,0)=mat[3][(7)%4]=mat[3][3]=0.42; no update → 0.42
794        // var=1: start get_2x_rev(3,1)=mat[3][(6)%4]=mat[3][2]=0.48; no update → 0.48
795        // var=2: start get_2x_rev(3,2)=mat[3][(5)%4]=mat[3][1]=0.55; no update → 0.55
796        // var=3: start get_2x_rev(3,3)=mat[3][(4)%4]=mat[3][0]=0.60; no update → 0.60
797        // before reverse: [0.42, 0.48, 0.55, 0.60]
798        // after  reverse: [0.60, 0.55, 0.48, 0.42]
799        let expected = [0.60, 0.55, 0.48, 0.42];
800        assert_eq!(adjusted.len(), p);
801        for (k, (&got, &exp)) in adjusted.iter().zip(expected.iter()).enumerate() {
802            assert!(
803                (got - exp).abs() < 1e-12,
804                "adjusted_pvalues[{k}]: got {got}, expected {exp}"
805            );
806        }
807    }
808
809    /// Verifies that `fisher_cf` never produces NaN or -inf on a 0.0 p-value.
810    #[test]
811    fn fisher_cf_log_safe() {
812        let v = fisher_cf(&[0.0, 0.5, 1.0]);
813        assert!(
814            v.is_finite(),
815            "fisher_cf must be finite even with 0.0 input: {v}"
816        );
817        // Clamped: -2*(ln(1e-300) + ln(0.5) + ln(1.0))
818        let expected = -2.0 * (1e-300f64.ln() + 0.5f64.ln() + 1.0f64.ln());
819        assert!(
820            (v - expected).abs() < 1e-10,
821            "fisher_cf value mismatch: {v} vs {expected}"
822        );
823    }
824
825    // ─── itp_one_pop tests ───────────────────────────────────────────────────
826
827    /// Generates a sample of sine curves with an optional additive shift on
828    /// the sub-interval `[shift_lo, shift_hi]`.
829    fn make_shifted_sample(
830        n: usize,
831        argvals: &[f64],
832        shift: f64,
833        shift_lo: f64,
834        shift_hi: f64,
835        seed: u64,
836    ) -> FdMatrix {
837        use rand::Rng;
838        let mut rng = StdRng::seed_from_u64(seed);
839        let m = argvals.len();
840        let mut data = FdMatrix::zeros(n, m);
841        for i in 0..n {
842            let phase: f64 = rng.gen::<f64>() * std::f64::consts::PI;
843            for (j, &t) in argvals.iter().enumerate() {
844                let noise: f64 = rng.gen::<f64>() * 0.05;
845                let s = if t >= shift_lo && t <= shift_hi {
846                    shift
847                } else {
848                    0.0
849                };
850                data[(i, j)] = (t * 2.0 * std::f64::consts::PI + phase).sin() + noise + s;
851            }
852        }
853        data
854    }
855
856    /// On a localized constant shift in [0.4, 0.6], at least one adjusted
857    /// p-value should be small (< 0.05). This tests that the ITP correctly
858    /// identifies a localized signal.
859    #[test]
860    fn one_population_localized() {
861        let m = 50;
862        let n = 30;
863        let argvals = uniform_grid(m);
864        // Shift of 2.0 on [0.4, 0.6]
865        let data = make_shifted_sample(n, &argvals, 2.0, 0.4, 0.6, 1001);
866        let result = itp_one_pop(
867            &data,
868            &argvals,
869            None,
870            ProjectionBasisType::Bspline,
871            15,
872            499,
873            42,
874        )
875        .expect("itp_one_pop should succeed");
876        assert_eq!(result.n_perm, 499);
877        assert!(!result.adjusted_pvalues.is_empty());
878        // At least one component should be significant
879        let min_p = result
880            .adjusted_pvalues
881            .iter()
882            .cloned()
883            .fold(f64::INFINITY, f64::min);
884        assert!(
885            min_p < 0.05,
886            "Expected at least one significant component, min adjusted p = {min_p}"
887        );
888    }
889
890    /// On a null sample (zero shift), all adjusted p-values should be
891    /// non-significant (max > 0.05, so most are not significant).
892    #[test]
893    fn one_population_null() {
894        let m = 50;
895        let n = 30;
896        let argvals = uniform_grid(m);
897        // No shift at all — pure sine + tiny noise
898        let data = make_shifted_sample(n, &argvals, 0.0, 0.0, 1.0, 2002);
899        let result = itp_one_pop(
900            &data,
901            &argvals,
902            None,
903            ProjectionBasisType::Bspline,
904            15,
905            499,
906            42,
907        )
908        .expect("itp_one_pop should succeed");
909        // Under the null, the max adjusted p-value should be non-significant
910        let max_p = result
911            .adjusted_pvalues
912            .iter()
913            .cloned()
914            .fold(f64::NEG_INFINITY, f64::max);
915        assert!(
916            max_p > 0.10,
917            "Expected non-significant result under null, max adjusted p = {max_p}"
918        );
919    }
920
921    /// Same inputs must produce bit-identical results.
922    #[test]
923    fn one_population_deterministic() {
924        let m = 30;
925        let n = 15;
926        let argvals = uniform_grid(m);
927        let data = make_shifted_sample(n, &argvals, 1.0, 0.3, 0.7, 3003);
928        let r1 = itp_one_pop(
929            &data,
930            &argvals,
931            None,
932            ProjectionBasisType::Bspline,
933            10,
934            99,
935            77,
936        )
937        .unwrap();
938        let r2 = itp_one_pop(
939            &data,
940            &argvals,
941            None,
942            ProjectionBasisType::Bspline,
943            10,
944            99,
945            77,
946        )
947        .unwrap();
948        assert_eq!(r1, r2, "same seed must give bit-identical ItpResult");
949    }
950
951    /// Invalid inputs must return FdarError, never panic.
952    #[test]
953    fn one_population_error_paths() {
954        let m = 20;
955        let argvals = uniform_grid(m);
956
957        // n < 2
958        let one_row = FdMatrix::zeros(1, m);
959        assert!(
960            matches!(
961                itp_one_pop(
962                    &one_row,
963                    &argvals,
964                    None,
965                    ProjectionBasisType::Bspline,
966                    5,
967                    99,
968                    0
969                ),
970                Err(FdarError::InvalidDimension { .. })
971            ),
972            "n < 2 should return InvalidDimension"
973        );
974
975        // argvals.len() != m
976        let data = FdMatrix::zeros(5, m);
977        let short_argvals = uniform_grid(m - 1);
978        assert!(
979            matches!(
980                itp_one_pop(
981                    &data,
982                    &short_argvals,
983                    None,
984                    ProjectionBasisType::Bspline,
985                    5,
986                    99,
987                    0
988                ),
989                Err(FdarError::InvalidDimension { .. })
990            ),
991            "argvals mismatch should return InvalidDimension"
992        );
993
994        // nbasis < 2
995        assert!(
996            matches!(
997                itp_one_pop(
998                    &data,
999                    &argvals,
1000                    None,
1001                    ProjectionBasisType::Bspline,
1002                    1,
1003                    99,
1004                    0
1005                ),
1006                Err(FdarError::InvalidParameter { .. })
1007            ),
1008            "nbasis < 2 should return InvalidParameter"
1009        );
1010
1011        // n_perm == 0
1012        assert!(
1013            matches!(
1014                itp_one_pop(&data, &argvals, None, ProjectionBasisType::Bspline, 5, 0, 0),
1015                Err(FdarError::InvalidParameter { .. })
1016            ),
1017            "n_perm == 0 should return InvalidParameter"
1018        );
1019    }
1020
1021    // ─── itp_two_pop tests ───────────────────────────────────────────────────
1022
1023    /// Build a sample of sine curves with an optional constant additive shift
1024    /// on `[shift_lo, shift_hi]` (used for both one-pop and two-pop fixtures).
1025    fn make_two_pop_sample(
1026        n: usize,
1027        argvals: &[f64],
1028        shift: f64,
1029        shift_lo: f64,
1030        shift_hi: f64,
1031        seed: u64,
1032    ) -> FdMatrix {
1033        use rand::Rng;
1034        let mut rng = StdRng::seed_from_u64(seed);
1035        let m = argvals.len();
1036        let mut data = FdMatrix::zeros(n, m);
1037        for i in 0..n {
1038            let phase: f64 = rng.gen::<f64>() * std::f64::consts::PI;
1039            for (j, &t) in argvals.iter().enumerate() {
1040                let noise: f64 = rng.gen::<f64>() * 0.05;
1041                let s = if t >= shift_lo && t <= shift_hi {
1042                    shift
1043                } else {
1044                    0.0
1045                };
1046                data[(i, j)] = (t * 2.0 * std::f64::consts::PI + phase).sin() + noise + s;
1047            }
1048        }
1049        data
1050    }
1051
1052    /// On a localized constant shift between groups, at least one adjusted
1053    /// p-value should be small (< 0.05).
1054    #[test]
1055    fn two_population_localized() {
1056        let m = 50;
1057        let n = 30;
1058        let argvals = uniform_grid(m);
1059        let data_a = make_two_pop_sample(n, &argvals, 0.0, 0.4, 0.6, 1001);
1060        // Group B has a shift of 2.0 on [0.4, 0.6]
1061        let data_b = make_two_pop_sample(n, &argvals, 2.0, 0.4, 0.6, 2002);
1062        let result = itp_two_pop(
1063            &data_a,
1064            &data_b,
1065            &argvals,
1066            ProjectionBasisType::Bspline,
1067            15,
1068            499,
1069            42,
1070        )
1071        .expect("itp_two_pop should succeed");
1072        assert_eq!(result.n_perm, 499);
1073        assert!(!result.adjusted_pvalues.is_empty());
1074        let min_p = result
1075            .adjusted_pvalues
1076            .iter()
1077            .cloned()
1078            .fold(f64::INFINITY, f64::min);
1079        assert!(
1080            min_p < 0.05,
1081            "Expected at least one significant component, min adjusted p = {min_p}"
1082        );
1083    }
1084
1085    /// Under the null (both groups same distribution), all adjusted p-values
1086    /// should be non-significant (max > 0.10).
1087    #[test]
1088    fn two_population_null() {
1089        let m = 50;
1090        let n = 30;
1091        let argvals = uniform_grid(m);
1092        let data_a = make_two_pop_sample(n, &argvals, 0.0, 0.0, 1.0, 3003);
1093        let data_b = make_two_pop_sample(n, &argvals, 0.0, 0.0, 1.0, 4004);
1094        let result = itp_two_pop(
1095            &data_a,
1096            &data_b,
1097            &argvals,
1098            ProjectionBasisType::Bspline,
1099            15,
1100            499,
1101            42,
1102        )
1103        .expect("itp_two_pop should succeed");
1104        let max_p = result
1105            .adjusted_pvalues
1106            .iter()
1107            .cloned()
1108            .fold(f64::NEG_INFINITY, f64::max);
1109        assert!(
1110            max_p > 0.10,
1111            "Expected non-significant result under null, max adjusted p = {max_p}"
1112        );
1113    }
1114
1115    /// Same seed must produce bit-identical results.
1116    #[test]
1117    fn two_population_deterministic() {
1118        let m = 30;
1119        let n = 15;
1120        let argvals = uniform_grid(m);
1121        let data_a = make_two_pop_sample(n, &argvals, 0.0, 0.0, 1.0, 5005);
1122        let data_b = make_two_pop_sample(n, &argvals, 1.0, 0.3, 0.7, 6006);
1123        let r1 = itp_two_pop(
1124            &data_a,
1125            &data_b,
1126            &argvals,
1127            ProjectionBasisType::Bspline,
1128            10,
1129            99,
1130            77,
1131        )
1132        .unwrap();
1133        let r2 = itp_two_pop(
1134            &data_a,
1135            &data_b,
1136            &argvals,
1137            ProjectionBasisType::Bspline,
1138            10,
1139            99,
1140            77,
1141        )
1142        .unwrap();
1143        assert_eq!(r1, r2, "same seed must give bit-identical ItpResult");
1144    }
1145
1146    /// Invalid inputs must return FdarError, never panic.
1147    #[test]
1148    fn two_population_error_paths() {
1149        let m = 20;
1150        let argvals = uniform_grid(m);
1151        let good = FdMatrix::zeros(5, m);
1152
1153        // n_a < 2
1154        let one_row = FdMatrix::zeros(1, m);
1155        assert!(
1156            matches!(
1157                itp_two_pop(
1158                    &one_row,
1159                    &good,
1160                    &argvals,
1161                    ProjectionBasisType::Bspline,
1162                    5,
1163                    99,
1164                    0
1165                ),
1166                Err(FdarError::InvalidDimension { .. })
1167            ),
1168            "n_a < 2 should return InvalidDimension"
1169        );
1170
1171        // n_b < 2
1172        assert!(
1173            matches!(
1174                itp_two_pop(
1175                    &good,
1176                    &one_row,
1177                    &argvals,
1178                    ProjectionBasisType::Bspline,
1179                    5,
1180                    99,
1181                    0
1182                ),
1183                Err(FdarError::InvalidDimension { .. })
1184            ),
1185            "n_b < 2 should return InvalidDimension"
1186        );
1187
1188        // m_a != m_b
1189        let wide = FdMatrix::zeros(5, m + 1);
1190        assert!(
1191            matches!(
1192                itp_two_pop(
1193                    &good,
1194                    &wide,
1195                    &argvals,
1196                    ProjectionBasisType::Bspline,
1197                    5,
1198                    99,
1199                    0
1200                ),
1201                Err(FdarError::InvalidDimension { .. })
1202            ),
1203            "m_a != m_b should return InvalidDimension"
1204        );
1205
1206        // argvals mismatch
1207        let short_argvals = uniform_grid(m - 1);
1208        assert!(
1209            matches!(
1210                itp_two_pop(
1211                    &good,
1212                    &good,
1213                    &short_argvals,
1214                    ProjectionBasisType::Bspline,
1215                    5,
1216                    99,
1217                    0
1218                ),
1219                Err(FdarError::InvalidDimension { .. })
1220            ),
1221            "argvals mismatch should return InvalidDimension"
1222        );
1223
1224        // nbasis < 2
1225        assert!(
1226            matches!(
1227                itp_two_pop(
1228                    &good,
1229                    &good,
1230                    &argvals,
1231                    ProjectionBasisType::Bspline,
1232                    1,
1233                    99,
1234                    0
1235                ),
1236                Err(FdarError::InvalidParameter { .. })
1237            ),
1238            "nbasis < 2 should return InvalidParameter"
1239        );
1240
1241        // n_perm == 0
1242        assert!(
1243            matches!(
1244                itp_two_pop(
1245                    &good,
1246                    &good,
1247                    &argvals,
1248                    ProjectionBasisType::Bspline,
1249                    5,
1250                    0,
1251                    0
1252                ),
1253                Err(FdarError::InvalidParameter { .. })
1254            ),
1255            "n_perm == 0 should return InvalidParameter"
1256        );
1257    }
1258
1259    // ─── itp_flm tests ───────────────────────────────────────────────────────
1260
1261    /// Build a sample where y is the mean of X over [lo, hi] plus small noise.
1262    /// This creates a strong, localized functional regression signal: curves with
1263    /// larger values on [lo, hi] have larger y.
1264    fn make_flm_sample(
1265        n: usize,
1266        argvals: &[f64],
1267        lo: f64,
1268        hi: f64,
1269        seed: u64,
1270    ) -> (FdMatrix, Vec<f64>) {
1271        use rand::Rng;
1272        let mut rng = StdRng::seed_from_u64(seed);
1273        let m = argvals.len();
1274        let mut data = FdMatrix::zeros(n, m);
1275        let mut y = vec![0.0f64; n];
1276        for i in 0..n {
1277            // Each curve is a random linear ramp with different slope/offset
1278            let scale: f64 = rng.gen::<f64>() * 3.0 + 1.0; // slope in [1, 4]
1279            let offset: f64 = (rng.gen::<f64>() - 0.5) * 2.0; // offset in [-1, 1]
1280            let mut local_sum = 0.0f64;
1281            let mut local_cnt = 0usize;
1282            for (j, &t) in argvals.iter().enumerate() {
1283                let noise: f64 = (rng.gen::<f64>() - 0.5) * 0.02;
1284                let v = scale * t + offset + noise;
1285                data[(i, j)] = v;
1286                if t >= lo && t <= hi {
1287                    local_sum += v;
1288                    local_cnt += 1;
1289                }
1290            }
1291            // y = mean of X on [lo, hi] plus tiny noise — strong local signal
1292            let y_noise: f64 = (rng.gen::<f64>() - 0.5) * 0.05;
1293            y[i] = if local_cnt > 0 {
1294                local_sum / local_cnt as f64
1295            } else {
1296                0.0
1297            } + y_noise;
1298        }
1299        (data, y)
1300    }
1301
1302    /// When y is the mean of X over [0.3, 0.7], at least one adjusted p-value
1303    /// should be significant (< 0.05) — a strong localized functional signal.
1304    #[test]
1305    fn flm_effect() {
1306        let m = 50;
1307        let n = 30;
1308        let argvals = uniform_grid(m);
1309        let (data, y) = make_flm_sample(n, &argvals, 0.3, 0.7, 7007);
1310        let result = itp_flm(
1311            &data,
1312            &y,
1313            &argvals,
1314            ProjectionBasisType::Bspline,
1315            15,
1316            499,
1317            42,
1318        )
1319        .expect("itp_flm should succeed");
1320        assert_eq!(result.n_perm, 499);
1321        assert!(!result.adjusted_pvalues.is_empty());
1322        let min_p = result
1323            .adjusted_pvalues
1324            .iter()
1325            .cloned()
1326            .fold(f64::INFINITY, f64::min);
1327        assert!(
1328            min_p < 0.05,
1329            "Expected at least one significant component with functional effect, min adjusted p = {min_p}"
1330        );
1331    }
1332
1333    /// When y is independent of X (pure noise), all adjusted p-values should
1334    /// be non-significant (max > 0.10).
1335    #[test]
1336    fn flm_null() {
1337        use rand::Rng;
1338        let m = 50;
1339        let n = 30;
1340        let argvals = uniform_grid(m);
1341        let mut rng = StdRng::seed_from_u64(8008);
1342        let mut data = FdMatrix::zeros(n, m);
1343        for i in 0..n {
1344            let phase: f64 = rng.gen::<f64>() * std::f64::consts::PI;
1345            for (j, &t) in argvals.iter().enumerate() {
1346                data[(i, j)] = (t * 2.0 * std::f64::consts::PI + phase).sin();
1347            }
1348        }
1349        // y is pure independent noise — no functional signal
1350        let y: Vec<f64> = (0..n).map(|_| rng.gen::<f64>()).collect();
1351        let result = itp_flm(
1352            &data,
1353            &y,
1354            &argvals,
1355            ProjectionBasisType::Bspline,
1356            15,
1357            499,
1358            42,
1359        )
1360        .expect("itp_flm should succeed");
1361        let max_p = result
1362            .adjusted_pvalues
1363            .iter()
1364            .cloned()
1365            .fold(f64::NEG_INFINITY, f64::max);
1366        assert!(
1367            max_p > 0.10,
1368            "Expected non-significant result under null, max adjusted p = {max_p}"
1369        );
1370    }
1371
1372    /// Invalid inputs must return FdarError, never panic.
1373    #[test]
1374    fn flm_error_paths() {
1375        let m = 20;
1376        let argvals = uniform_grid(m);
1377        let data = FdMatrix::zeros(5, m);
1378        let y = vec![0.0f64; 5];
1379
1380        // n < 2
1381        let one_row = FdMatrix::zeros(1, m);
1382        let y1 = vec![0.0f64; 1];
1383        assert!(
1384            matches!(
1385                itp_flm(
1386                    &one_row,
1387                    &y1,
1388                    &argvals,
1389                    ProjectionBasisType::Bspline,
1390                    5,
1391                    99,
1392                    0
1393                ),
1394                Err(FdarError::InvalidDimension { .. })
1395            ),
1396            "n < 2 should return InvalidDimension"
1397        );
1398
1399        // y.len() != n
1400        let y_wrong = vec![0.0f64; 3];
1401        assert!(
1402            matches!(
1403                itp_flm(
1404                    &data,
1405                    &y_wrong,
1406                    &argvals,
1407                    ProjectionBasisType::Bspline,
1408                    5,
1409                    99,
1410                    0
1411                ),
1412                Err(FdarError::InvalidDimension { .. })
1413            ),
1414            "y.len() != n should return InvalidDimension"
1415        );
1416
1417        // argvals mismatch
1418        let short_argvals = uniform_grid(m - 1);
1419        assert!(
1420            matches!(
1421                itp_flm(
1422                    &data,
1423                    &y,
1424                    &short_argvals,
1425                    ProjectionBasisType::Bspline,
1426                    5,
1427                    99,
1428                    0
1429                ),
1430                Err(FdarError::InvalidDimension { .. })
1431            ),
1432            "argvals mismatch should return InvalidDimension"
1433        );
1434
1435        // nbasis < 2
1436        assert!(
1437            matches!(
1438                itp_flm(&data, &y, &argvals, ProjectionBasisType::Bspline, 1, 99, 0),
1439                Err(FdarError::InvalidParameter { .. })
1440            ),
1441            "nbasis < 2 should return InvalidParameter"
1442        );
1443
1444        // n_perm == 0
1445        assert!(
1446            matches!(
1447                itp_flm(&data, &y, &argvals, ProjectionBasisType::Bspline, 5, 0, 0),
1448                Err(FdarError::InvalidParameter { .. })
1449            ),
1450            "n_perm == 0 should return InvalidParameter"
1451        );
1452    }
1453}