use super::dist::f_sf;
use super::TestResult;
use crate::error::FdarError;
use crate::scalar_on_function::FregreLmResult;
pub fn flm_f_test(fit: &FregreLmResult) -> Result<TestResult, FdarError> {
let p = fit.ncomp;
let n = fit.residuals.len();
let r2 = fit.r_squared;
if p == 0 {
return Err(FdarError::InvalidParameter {
parameter: "fit.ncomp",
message: "flm_f_test requires ncomp >= 1 (at least one FPC parameter)".to_string(),
});
}
if n <= p + 1 {
return Err(FdarError::InvalidParameter {
parameter: "fit",
message: format!(
"degenerate degrees of freedom: n - p - 1 = {} - {} - 1 <= 0",
n, p
),
});
}
if !r2.is_finite() || r2 >= 1.0 {
return Err(FdarError::InvalidParameter {
parameter: "fit.r_squared",
message: format!(
"r_squared must be finite and < 1.0 for a well-defined F statistic, got {r2}"
),
});
}
let d1 = p as f64;
let d2 = (n - p - 1) as f64;
let f_stat = (r2 / d1) / ((1.0 - r2) / d2);
let p_value = f_sf(f_stat, d1, d2);
Ok(TestResult {
statistic: f_stat,
p_value,
n_perm: 0,
})
}
pub fn flm_gof_test(fit: &FregreLmResult) -> Result<TestResult, FdarError> {
const Q: usize = 3;
let e = &fit.residuals;
let yhat = &fit.fitted_values;
let n = e.len();
if yhat.len() != n {
return Err(FdarError::InvalidParameter {
parameter: "fit",
message: format!(
"residuals ({}) and fitted_values ({}) must have equal length",
n,
yhat.len()
),
});
}
if n <= Q + 1 {
return Err(FdarError::InvalidParameter {
parameter: "fit",
message: format!(
"degenerate degrees of freedom for GoF: n = {n} <= {}",
Q + 1
),
});
}
if e.iter().chain(yhat.iter()).any(|v| !v.is_finite()) {
return Err(FdarError::InvalidParameter {
parameter: "fit",
message: "residuals / fitted_values contain non-finite values".to_string(),
});
}
let mean_yhat = yhat.iter().sum::<f64>() / n as f64;
let var_yhat = yhat.iter().map(|&v| (v - mean_yhat).powi(2)).sum::<f64>() / n as f64;
if var_yhat <= 1e-30 {
return Err(FdarError::InvalidParameter {
parameter: "fit.fitted_values",
message: "fitted values are (numerically) constant; GoF design is rank-deficient"
.to_string(),
});
}
let sd_yhat = var_yhat.sqrt();
let ncoef = Q + 1;
let mut x: Vec<[f64; 4]> = Vec::with_capacity(n);
for &yh in yhat {
let z = (yh - mean_yhat) / sd_yhat;
x.push([1.0, z, z * z, z * z * z]);
}
let mut xtx = [[0.0f64; 4]; 4];
let mut xte = [0.0f64; 4];
for (row, &ei) in x.iter().zip(e.iter()) {
for a in 0..ncoef {
xte[a] += row[a] * ei;
for b in 0..ncoef {
xtx[a][b] += row[a] * row[b];
}
}
}
let coef = match solve_linear(xtx, xte, ncoef) {
Some(c) => c,
None => {
return Err(FdarError::InvalidParameter {
parameter: "fit.fitted_values",
message: "GoF auxiliary design is singular (rank-deficient fitted values)"
.to_string(),
});
}
};
let mut rss_full = 0.0;
for (row, &ei) in x.iter().zip(e.iter()) {
let pred = (0..ncoef).map(|a| coef[a] * row[a]).sum::<f64>();
rss_full += (ei - pred).powi(2);
}
let mean_e = e.iter().sum::<f64>() / n as f64;
let rss_restricted = e.iter().map(|&ei| (ei - mean_e).powi(2)).sum::<f64>();
let df_num = Q as f64; let df_den = (n - ncoef) as f64;
if rss_full <= 1e-30 || rss_restricted <= 1e-30 {
return Ok(TestResult {
statistic: 0.0,
p_value: 1.0,
n_perm: 0,
});
}
let f_stat = ((rss_restricted - rss_full) / df_num) / (rss_full / df_den);
let f_stat = f_stat.max(0.0);
let p_value = f_sf(f_stat, df_num, df_den);
Ok(TestResult {
statistic: f_stat,
p_value,
n_perm: 0,
})
}
fn solve_linear(a: [[f64; 4]; 4], b: [f64; 4], n: usize) -> Option<[f64; 4]> {
let mut m = a;
let mut rhs = b;
for col in 0..n {
let mut pivot = col;
let mut best = m[col][col].abs();
for r in (col + 1)..n {
if m[r][col].abs() > best {
best = m[r][col].abs();
pivot = r;
}
}
if best < 1e-12 {
return None;
}
if pivot != col {
m.swap(pivot, col);
rhs.swap(pivot, col);
}
let diag = m[col][col];
for r in (col + 1)..n {
let factor = m[r][col] / diag;
for c in col..n {
m[r][c] -= factor * m[col][c];
}
rhs[r] -= factor * rhs[col];
}
}
let mut sol = [0.0f64; 4];
for i in (0..n).rev() {
let mut s = rhs[i];
for c in (i + 1)..n {
s -= m[i][c] * sol[c];
}
sol[i] = s / m[i][i];
}
Some(sol)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::matrix::FdMatrix;
use crate::scalar_on_function::fregre_lm;
use crate::test_helpers::uniform_grid;
fn noise(seed: &mut u64) -> f64 {
*seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let z = (*seed >> 33) as f64 / (1u64 << 31) as f64; z - 1.0
}
fn make_curves(n: usize, argvals: &[f64], amps: &[f64], seed: u64) -> FdMatrix {
let m = argvals.len();
let mut s = seed;
let mut mat = FdMatrix::zeros(n, m);
for i in 0..n {
for (j, &t) in argvals.iter().enumerate() {
let base = amps[i] * (2.0 * std::f64::consts::PI * t).sin();
mat[(i, j)] = base + 0.05 * noise(&mut s);
}
}
mat
}
#[test]
fn f_test_rejects_genuine_effect() {
let argvals = uniform_grid(40);
let n = 40;
let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
let data = make_curves(n, &argvals, &s, 7);
let mut s = 1234u64;
let y: Vec<f64> = (0..n)
.map(|i| 3.0 * amps[i] + 0.1 * noise(&mut s))
.collect();
let fit = fregre_lm(&data, &y, None, 3).unwrap();
let res = flm_f_test(&fit).unwrap();
assert!(
res.p_value < 0.05,
"genuine functional effect should reject H0, got p={} (F={})",
res.p_value,
res.statistic
);
}
#[test]
fn f_test_fails_to_reject_null_effect() {
let argvals = uniform_grid(40);
let n = 40;
let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
let data = make_curves(n, &argvals, &s, 71);
let mut s = 98765u64;
let y: Vec<f64> = (0..n).map(|_| 5.0 + noise(&mut s)).collect();
let fit = fregre_lm(&data, &y, None, 3).unwrap();
let res = flm_f_test(&fit).unwrap();
assert!(
res.p_value > 0.20,
"null-effect fit should fail to reject H0, got p={} (F={}, R²={})",
res.p_value,
res.statistic,
fit.r_squared
);
}
#[test]
fn f_test_guards_degenerate_df() {
let argvals = uniform_grid(20);
let n = 5;
let amps: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
let data = make_curves(n, &argvals, &s, 3);
let mut s = 5u64;
let y: Vec<f64> = (0..n)
.map(|i| 2.0 * amps[i] + 0.1 * noise(&mut s))
.collect();
let fit = fregre_lm(&data, &y, None, 4).unwrap();
assert!(
matches!(flm_f_test(&fit), Err(FdarError::InvalidParameter { .. })),
"degenerate df must return Err"
);
}
#[test]
fn gof_fails_to_reject_well_specified() {
let argvals = uniform_grid(50);
let n = 60;
let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
let data = make_curves(n, &argvals, &s, 21);
let mut s = 55u64;
let y: Vec<f64> = (0..n)
.map(|i| 2.0 * amps[i] + 0.05 * noise(&mut s))
.collect();
let fit = fregre_lm(&data, &y, None, 3).unwrap();
let res = flm_gof_test(&fit).unwrap();
assert!(
res.p_value > 0.10,
"well-specified linear FLM should not be flagged, got p={} (F={})",
res.p_value,
res.statistic
);
}
#[test]
fn gof_rejects_mis_specified() {
let argvals = uniform_grid(50);
let n = 60;
let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
let data = make_curves(n, &argvals, &s, 22);
let mut s = 66u64;
let y: Vec<f64> = (0..n)
.map(|i| 4.0 * amps[i] * amps[i] + 0.05 * noise(&mut s))
.collect();
let fit = fregre_lm(&data, &y, None, 3).unwrap();
let res = flm_gof_test(&fit).unwrap();
assert!(
res.p_value < 0.05,
"mis-specified (nonlinear) FLM should be flagged, got p={} (F={})",
res.p_value,
res.statistic
);
}
#[test]
fn gof_guards_degenerate_df() {
let argvals = uniform_grid(20);
let n = 4;
let amps: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
let data = make_curves(n, &argvals, &s, 9);
let mut s = 4u64;
let y: Vec<f64> = (0..n).map(|i| amps[i] + 0.1 * noise(&mut s)).collect();
let fit = fregre_lm(&data, &y, None, 2).unwrap();
assert!(matches!(
flm_gof_test(&fit),
Err(FdarError::InvalidParameter { .. })
));
}
}