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(¢ered, 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}