use faer::Side;
use gam_linalg::faer_ndarray::strict_symmetric_eigh;
use gam_math::probability::{chi_square_sf, fisher_snedecor_sf};
use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
pub use crate::inference::smooth_test::SmoothTestScale;
pub struct BasisAdequacyInput<'a> {
pub enrichment: ArrayView2<'a, f64>,
pub design: ArrayView2<'a, f64>,
pub hessian_weights: ArrayView1<'a, f64>,
pub score_weights: ArrayView1<'a, f64>,
pub score: ArrayView1<'a, f64>,
pub design_gram: &'a DesignGramFactor,
pub dispersion: f64,
pub residual_df: Option<f64>,
pub scale: SmoothTestScale,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BasisAdequacyResult {
pub statistic: f64,
pub rank: usize,
pub p_value: f64,
}
pub fn basis_adequacy_score_test(input: BasisAdequacyInput<'_>) -> Option<BasisAdequacyResult> {
let m = input.design.nrows();
let p = input.design.ncols();
let q = input.enrichment.ncols();
if m == 0
|| p == 0
|| q == 0
|| input.enrichment.nrows() != m
|| input.hessian_weights.len() != m
|| input.score_weights.len() != m
|| input.score.len() != m
|| input.design_gram.dimension() != p
|| !(input.dispersion.is_finite() && input.dispersion > 0.0)
{
return None;
}
const ROW_BLOCK: usize = 4096;
let mut cross = Array2::<f64>::zeros((p, q));
let mut raw_information = Array2::<f64>::zeros((q, q));
let mut start = 0usize;
while start < m {
let stop = (start + ROW_BLOCK).min(m);
let block = input.enrichment.slice(ndarray::s![start..stop, ..]);
let mut hessian_weighted = block.to_owned();
let mut fisher_weighted = block.to_owned();
for local in 0..(stop - start) {
let curvature = input.hessian_weights[start + local];
let fisher = input.score_weights[start + local];
if !curvature.is_finite() || !(fisher.is_finite() && fisher >= 0.0) {
return None;
}
hessian_weighted
.row_mut(local)
.iter_mut()
.for_each(|value| *value *= curvature);
fisher_weighted
.row_mut(local)
.iter_mut()
.for_each(|value| *value *= fisher);
}
raw_information += &block.t().dot(&fisher_weighted);
cross += &input
.design
.slice(ndarray::s![start..stop, ..])
.t()
.dot(&hessian_weighted);
start = stop;
}
let energy_scale = (0..q).fold(0.0_f64, |widest, column| {
widest.max(raw_information[(column, column)])
});
if !(energy_scale > 0.0)
|| raw_information.iter().any(|value| !value.is_finite())
|| cross.iter().any(|value| !value.is_finite())
{
return None;
}
let coefficient_shift = input.design_gram.solve(&cross)?;
if coefficient_shift.iter().any(|value| !value.is_finite()) {
return None;
}
let mut information = Array2::<f64>::zeros((q, q));
let mut u = Array1::<f64>::zeros(q);
let mut start = 0usize;
while start < m {
let stop = (start + ROW_BLOCK).min(m);
let rows = stop - start;
let mut residualized = input
.enrichment
.slice(ndarray::s![start..stop, ..])
.to_owned();
residualized -= &input
.design
.slice(ndarray::s![start..stop, ..])
.dot(&coefficient_shift);
u += &residualized
.t()
.dot(&input.score.slice(ndarray::s![start..stop]));
let mut weighted = residualized.clone();
for local in 0..rows {
let weight = input.score_weights[start + local];
if !(weight.is_finite() && weight >= 0.0) {
return None;
}
weighted
.row_mut(local)
.iter_mut()
.for_each(|value| *value *= weight);
}
information += &residualized.t().dot(&weighted);
start = stop;
}
if information.iter().any(|value| !value.is_finite())
|| u.iter().any(|value| !value.is_finite())
{
return None;
}
let symmetric = 0.5 * (&information + &information.t());
let (information_values, information_vectors) =
strict_symmetric_eigh(&symmetric, Side::Lower).ok()?;
let information_max = information_values.iter().cloned().fold(0.0_f64, f64::max);
if !(information_max > 0.0) {
return None;
}
let information_floor = information_max * (q as f64) * f64::EPSILON;
let realized: Vec<usize> = (0..q)
.filter(|&column| information_values[column] > information_floor)
.collect();
if realized.is_empty() {
return None;
}
let mut whitening = Array2::<f64>::zeros((q, realized.len()));
for (slot, &column) in realized.iter().enumerate() {
let scale = 1.0 / information_values[column].sqrt();
if !scale.is_finite() {
return None;
}
for row in 0..q {
whitening[(row, slot)] = information_vectors[(row, column)] * scale;
}
}
let symmetric_energy = 0.5 * (&raw_information + &raw_information.t());
let retained = whitening.t().dot(&symmetric_energy).dot(&whitening);
let retained = 0.5 * (&retained + &retained.t());
if retained.iter().any(|value| !value.is_finite()) {
return None;
}
let (raw_energy_per_residual, rotation) = strict_symmetric_eigh(&retained, Side::Lower).ok()?;
let raw_energy_scale = raw_energy_per_residual
.iter()
.map(|value| value.abs())
.fold(0.0_f64, f64::max);
let psd_roundoff = raw_energy_scale * (realized.len() as f64) * f64::EPSILON;
if raw_energy_per_residual
.iter()
.any(|value| *value < -psd_roundoff)
{
return None;
}
let projected: Array1<f64> = rotation.t().dot(&whitening.t().dot(&u));
let geometry_floor = (p.max(q) as f64) * f64::EPSILON;
let mut statistic = 0.0_f64;
let mut rank = 0usize;
for (index, &raw_energy) in raw_energy_per_residual.iter().enumerate() {
let raw_energy = raw_energy.max(0.0);
if raw_energy * geometry_floor < 1.0 {
let component = projected[index];
statistic += component * component;
rank += 1;
}
}
if rank == 0 {
return None;
}
let statistic = statistic / input.dispersion;
if !statistic.is_finite() || statistic < 0.0 {
return None;
}
let reference_df = rank as f64;
let p_value = match input.scale {
SmoothTestScale::Known => chi_square_sf(statistic, reference_df),
SmoothTestScale::Estimated => {
let residual_df = input
.residual_df
.filter(|value| value.is_finite() && *value > 0.0)?;
fisher_snedecor_sf(statistic / reference_df, reference_df, residual_df)
}
};
if !p_value.is_finite() {
return None;
}
Some(BasisAdequacyResult {
statistic,
rank,
p_value,
})
}
pub fn gather_design_rows(
design: &gam_linalg::matrix::DesignMatrix,
rows: &[usize],
) -> Option<Array2<f64>> {
const ROW_BLOCK: usize = 4096;
let n_total = design.nrows();
let p = design.ncols();
if p == 0
|| rows.is_empty()
|| rows.last().is_some_and(|last| *last >= n_total)
|| rows.windows(2).any(|pair| pair[0] >= pair[1])
{
return None;
}
let mut gathered = Array2::<f64>::zeros((rows.len(), p));
let mut selected = 0usize;
let mut start = 0usize;
while start < n_total && selected < rows.len() {
let stop = (start + ROW_BLOCK).min(n_total);
let first = selected;
while selected < rows.len() && rows[selected] < stop {
selected += 1;
}
if selected > first {
let block = design.try_row_chunk(start..stop).ok()?;
for (offset, &row) in rows[first..selected].iter().enumerate() {
gathered
.row_mut(first + offset)
.assign(&block.row(row - start));
}
}
start = stop;
}
gathered
.iter()
.all(|value| value.is_finite())
.then_some(gathered)
}
pub fn weighted_gram(
design: ArrayView2<'_, f64>,
weights: ArrayView1<'_, f64>,
) -> Option<Array2<f64>> {
let m = design.nrows();
if m == 0 || design.ncols() == 0 || weights.len() != m {
return None;
}
let mut weighted = design.to_owned();
for row in 0..m {
let weight = weights[row];
if !weight.is_finite() {
return None;
}
weighted.row_mut(row).iter_mut().for_each(|v| *v *= weight);
}
let gram = design.t().dot(&weighted);
gram.iter().all(|value| value.is_finite()).then_some(gram)
}
pub struct DesignGramFactor {
kind: DesignGramFactorKind,
dimension: usize,
}
enum DesignGramFactorKind {
Cholesky(gam_linalg::faer_ndarray::FaerCholeskyFactor),
SpectralPseudoInverse(Array2<f64>),
}
impl DesignGramFactor {
pub fn new(gram: ArrayView2<'_, f64>) -> Option<Self> {
use gam_linalg::faer_ndarray::FaerCholesky;
let dimension = gram.nrows();
if dimension == 0
|| gram.ncols() != dimension
|| gram.iter().any(|value| !value.is_finite())
{
return None;
}
let owned = gram.to_owned();
if let Ok(factor) = owned.cholesky(Side::Lower) {
return Some(Self {
kind: DesignGramFactorKind::Cholesky(factor),
dimension,
});
}
let symmetric = 0.5 * (&owned + &owned.t());
let (eigenvalues, eigenvectors) = strict_symmetric_eigh(&symmetric, Side::Lower).ok()?;
let largest = eigenvalues.iter().cloned().fold(0.0_f64, f64::max);
if !(largest > 0.0) {
return None;
}
let floor = largest * (dimension as f64) * f64::EPSILON;
let mut scaled = eigenvectors.clone();
for (index, &eigenvalue) in eigenvalues.iter().enumerate() {
let factor = if eigenvalue > floor {
1.0 / eigenvalue
} else {
0.0
};
scaled
.column_mut(index)
.iter_mut()
.for_each(|v| *v *= factor);
}
let pseudo_inverse = scaled.dot(&eigenvectors.t());
pseudo_inverse
.iter()
.all(|value| value.is_finite())
.then_some(Self {
kind: DesignGramFactorKind::SpectralPseudoInverse(pseudo_inverse),
dimension,
})
}
pub fn dimension(&self) -> usize {
self.dimension
}
fn solve(&self, rhs: &Array2<f64>) -> Option<Array2<f64>> {
let solved = match &self.kind {
DesignGramFactorKind::Cholesky(factor) => factor.solve_mat(rhs),
DesignGramFactorKind::SpectralPseudoInverse(inverse) => inverse.dot(rhs),
};
solved
.iter()
.all(|value| value.is_finite())
.then_some(solved)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::{Array1, Array2, array};
struct Lcg(u64);
impl Lcg {
fn next_uniform(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
}
fn next_normal(&mut self) -> f64 {
let u1 = self.next_uniform().max(1e-12);
let u2 = self.next_uniform();
(-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
}
struct GaussianHarness {
design: Array2<f64>,
enrichment: Array2<f64>,
weights: Array1<f64>,
score: Array1<f64>,
design_gram: DesignGramFactor,
}
impl GaussianHarness {
fn new(design: Array2<f64>, enrichment: Array2<f64>, y: Array1<f64>, ridge: f64) -> Self {
let n = design.nrows();
let p = design.ncols();
let gram = design.t().dot(&design);
let mut hessian = gram.clone();
for index in 0..p {
hessian[(index, index)] += ridge;
}
let beta = invert_symmetric(&hessian).dot(&design.t().dot(&y));
let score = &y - &design.dot(&beta);
Self {
design,
enrichment,
weights: Array1::ones(n),
score,
design_gram: DesignGramFactor::new(gram.view())
.expect("test harness Gram is factorable"),
}
}
fn input(&self) -> BasisAdequacyInput<'_> {
BasisAdequacyInput {
enrichment: self.enrichment.view(),
design: self.design.view(),
hessian_weights: self.weights.view(),
score_weights: self.weights.view(),
score: self.score.view(),
design_gram: &self.design_gram,
dispersion: 1.0,
residual_df: None,
scale: SmoothTestScale::Known,
}
}
}
fn invert_symmetric(matrix: &Array2<f64>) -> Array2<f64> {
let (values, vectors) = strict_symmetric_eigh(matrix, Side::Lower)
.expect("test harness matrix is symmetric positive definite");
let mut inverse = Array2::<f64>::zeros(matrix.raw_dim());
for (index, &value) in values.iter().enumerate() {
let column = vectors.column(index);
let scale = 1.0 / value;
for row in 0..matrix.nrows() {
for col in 0..matrix.ncols() {
inverse[(row, col)] += scale * column[row] * column[col];
}
}
}
inverse
}
#[test]
fn enrichment_inside_the_fitted_span_has_no_estimable_direction() {
let design = array![
[1.0, 0.0],
[1.0, 1.0],
[1.0, 2.0],
[1.0, 3.0],
[1.0, 4.0],
[1.0, 5.0]
];
let enrichment = design.dot(&array![[2.0, -1.0], [0.5, 3.0]]);
let y = array![0.3, -0.2, 0.7, 0.1, -0.5, 0.4];
let harness = GaussianHarness::new(design, enrichment, y, 0.0);
assert_eq!(basis_adequacy_score_test(harness.input()), None);
}
#[test]
fn rank_counts_only_directions_outside_the_fitted_span() {
let mut rng = Lcg(20_260_823);
let n = 200;
let mut design = Array2::<f64>::zeros((n, 2));
let mut enrichment = Array2::<f64>::zeros((n, 3));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = row as f64 / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
enrichment[(row, 0)] = x; enrichment[(row, 1)] = x * x;
enrichment[(row, 2)] = x * x * x;
y[row] = 0.5 + 2.0 * x + 0.1 * rng.next_normal();
}
let harness = GaussianHarness::new(design, enrichment, y, 0.0);
let out = basis_adequacy_score_test(harness.input())
.expect("two enrichment directions remain estimable");
assert_eq!(out.rank, 2);
}
#[test]
fn null_statistic_has_mean_near_its_reference_df() {
let n = 400;
let replicates = 200;
let mut rng = Lcg(1_234_567);
let mut total = 0.0;
let mut rank_seen = 0usize;
let mut rejections = 0usize;
for _ in 0..replicates {
let mut design = Array2::<f64>::zeros((n, 2));
let mut enrichment = Array2::<f64>::zeros((n, 3));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = (row as f64 + 0.5) / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
enrichment[(row, 0)] = x * x;
enrichment[(row, 1)] = x * x * x;
enrichment[(row, 2)] = (6.0 * x).sin();
y[row] = 0.5 + 2.0 * x + rng.next_normal();
}
let harness = GaussianHarness::new(design, enrichment, y, 0.0);
let out = basis_adequacy_score_test(harness.input()).expect("estimable enrichment");
total += out.statistic;
rank_seen = out.rank;
if out.p_value < 0.05 {
rejections += 1;
}
}
let mean = total / replicates as f64;
let expected = rank_seen as f64;
assert!(
(mean - expected).abs() < 0.7,
"null mean statistic {mean} should sit near rank {expected}"
);
let size = rejections as f64 / replicates as f64;
assert!(size < 0.12, "null rejection rate {size} is inflated");
}
#[test]
fn missing_curvature_is_detected() {
let n = 400;
let mut rng = Lcg(7_654_321);
let mut design = Array2::<f64>::zeros((n, 2));
let mut enrichment = Array2::<f64>::zeros((n, 3));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = (row as f64 + 0.5) / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
enrichment[(row, 0)] = x * x;
enrichment[(row, 1)] = x * x * x;
enrichment[(row, 2)] = (6.0 * x).sin();
y[row] = 0.5 + 2.0 * x + 3.0 * x * x + rng.next_normal();
}
let harness = GaussianHarness::new(design, enrichment, y, 0.0);
let out = basis_adequacy_score_test(harness.input()).expect("estimable enrichment");
assert!(
out.p_value < 1e-3,
"quadratic lack of fit should be detected, got p={}",
out.p_value
);
}
#[test]
fn statistic_is_invariant_to_shifting_the_enrichment_by_design_columns() {
let n = 300;
let mut rng = Lcg(4_242);
let mut design = Array2::<f64>::zeros((n, 3));
let mut enrichment = Array2::<f64>::zeros((n, 2));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = (row as f64 + 0.5) / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
design[(row, 2)] = (3.0 * x).cos();
enrichment[(row, 0)] = x * x;
enrichment[(row, 1)] = (5.0 * x).sin();
y[row] = 1.0 + x + 0.8 * x * x + 0.4 * rng.next_normal();
}
let base = GaussianHarness::new(design.clone(), enrichment.clone(), y.clone(), 40.0);
let shift = array![[7.0, -2.0], [0.5, 3.0], [-1.5, 4.0]];
let shifted_enrichment = &enrichment + &design.dot(&shift);
let shifted = GaussianHarness::new(design, shifted_enrichment, y, 40.0);
let a = basis_adequacy_score_test(base.input()).expect("base result");
let b = basis_adequacy_score_test(shifted.input()).expect("shifted result");
assert_eq!(a.rank, b.rank);
assert!(
(a.statistic - b.statistic).abs() <= 1e-8 * a.statistic.max(1.0),
"statistic must not move under Z -> Z + X·A; got {} vs {}",
a.statistic,
b.statistic
);
}
#[test]
fn statistic_does_not_track_the_penalty_strength_under_the_null() {
let n = 500;
let mut rng = Lcg(31_337);
let mut design = Array2::<f64>::zeros((n, 3));
let mut enrichment = Array2::<f64>::zeros((n, 4));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = (row as f64 + 0.5) / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
design[(row, 2)] = x * x;
enrichment[(row, 0)] = x * x * x;
enrichment[(row, 1)] = (7.0 * x).sin();
enrichment[(row, 2)] = (7.0 * x).cos();
enrichment[(row, 3)] = (11.0 * x).sin();
y[row] = 1.0 + 3.0 * x - 2.0 * x * x + rng.next_normal();
}
let mut statistics = Vec::new();
for ridge in [0.0, 1.0, 1.0e2, 1.0e4] {
let harness =
GaussianHarness::new(design.clone(), enrichment.clone(), y.clone(), ridge);
let out = basis_adequacy_score_test(harness.input()).expect("estimable enrichment");
statistics.push(out.statistic);
}
let span = statistics.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
- statistics.iter().cloned().fold(f64::INFINITY, f64::min);
assert!(
span < 4.0,
"the ridge must not drive the null statistic; got {statistics:?}"
);
}
#[test]
fn gathering_selects_the_requested_rows_from_any_backing() {
let mut source = Array2::<f64>::zeros((10, 3));
for row in 0..10 {
for column in 0..3 {
source[(row, column)] = (row * 3 + column) as f64;
}
}
let design = gam_linalg::matrix::DesignMatrix::Dense(
gam_linalg::matrix::DenseDesignMatrix::from(source.clone()),
);
let rows = [0usize, 4, 5, 9];
let gathered = gather_design_rows(&design, &rows).expect("rows are in range and sorted");
assert_eq!(gathered.dim(), (4, 3));
for (local, &row) in rows.iter().enumerate() {
assert_eq!(gathered.row(local), source.row(row));
}
assert!(gather_design_rows(&design, &[0, 10]).is_none());
assert!(gather_design_rows(&design, &[4, 4]).is_none());
assert!(gather_design_rows(&design, &[4, 1]).is_none());
assert!(gather_design_rows(&design, &[]).is_none());
}
#[test]
fn a_row_subset_gives_the_same_answer_as_the_subset_design() {
let n = 600;
let mut rng = Lcg(5_150);
let mut design = Array2::<f64>::zeros((n, 3));
let mut enrichment = Array2::<f64>::zeros((n, 3));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = (row as f64 + 0.5) / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
design[(row, 2)] = (2.0 * x).cos();
enrichment[(row, 0)] = x * x;
enrichment[(row, 1)] = (9.0 * x).sin();
enrichment[(row, 2)] = x * x * x;
y[row] = 0.4 + 1.3 * x + 0.9 * x * x + 0.3 * rng.next_normal();
}
let full = GaussianHarness::new(design.clone(), enrichment.clone(), y, 2.0);
let rows: Vec<usize> = (0..n).step_by(3).collect();
let sub_design = select(&design, &rows);
let sub_enrichment = select(&enrichment, &rows);
let sub_weights = Array1::<f64>::ones(rows.len());
let sub_score = Array1::from_iter(rows.iter().map(|&row| full.score[row]));
let sub_gram = weighted_gram(sub_design.view(), sub_weights.view())
.and_then(|gram| DesignGramFactor::new(gram.view()))
.expect("the subset Gram factors");
let subset = basis_adequacy_score_test(BasisAdequacyInput {
enrichment: sub_enrichment.view(),
design: sub_design.view(),
hessian_weights: sub_weights.view(),
score_weights: sub_weights.view(),
score: sub_score.view(),
design_gram: &sub_gram,
dispersion: 1.0,
residual_df: None,
scale: SmoothTestScale::Known,
})
.expect("the subset carries estimable directions");
let backing = gam_linalg::matrix::DesignMatrix::Dense(
gam_linalg::matrix::DenseDesignMatrix::from(design),
);
let gathered = gather_design_rows(&backing, &rows).expect("gather");
let gathered_gram = weighted_gram(gathered.view(), sub_weights.view())
.and_then(|gram| DesignGramFactor::new(gram.view()))
.expect("the gathered Gram factors");
let via_gather = basis_adequacy_score_test(BasisAdequacyInput {
enrichment: sub_enrichment.view(),
design: gathered.view(),
hessian_weights: sub_weights.view(),
score_weights: sub_weights.view(),
score: sub_score.view(),
design_gram: &gathered_gram,
dispersion: 1.0,
residual_df: None,
scale: SmoothTestScale::Known,
})
.expect("the gathered subset carries estimable directions");
assert_eq!(subset, via_gather);
let full_result = basis_adequacy_score_test(full.input()).expect("full result");
assert_eq!(full_result.rank, subset.rank);
assert!(full_result.statistic > subset.statistic);
}
#[test]
fn an_absorbed_column_does_not_decide_the_other_directions() {
let n = 200;
let mut design = Array2::<f64>::zeros((n, 2));
let mut lean = Array2::<f64>::zeros((n, 2));
let mut padded = Array2::<f64>::zeros((n, 3));
let mut y = Array1::<f64>::zeros(n);
let mut rng = Lcg(2_788_2_789);
for row in 0..n {
let x = row as f64 / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
lean[(row, 0)] = x * x;
lean[(row, 1)] = x * x * x;
padded[(row, 0)] = 1.0e4 * x;
padded[(row, 1)] = x * x;
padded[(row, 2)] = x * x * x;
y[row] = 0.5 + 2.0 * x + 3.0 * x * x + 0.1 * rng.next_normal();
}
let lean_out = basis_adequacy_score_test(
GaussianHarness::new(design.clone(), lean, y.clone(), 0.0).input(),
)
.expect("the quadratic and cubic directions are estimable");
let padded_out =
basis_adequacy_score_test(GaussianHarness::new(design, padded, y, 0.0).input())
.expect("padding with an absorbed column may not blind the test");
assert_eq!(lean_out.rank, 2);
assert_eq!(
padded_out.rank, 2,
"an exact design column is not new resolution, and it is not a floor either"
);
let relative =
(padded_out.statistic - lean_out.statistic).abs() / lean_out.statistic.max(1.0);
assert!(
relative < 1e-6,
"the statistic moved with a column carrying no estimable direction: \
{} vs {}",
padded_out.statistic,
lean_out.statistic
);
}
#[test]
fn reference_df_grows_with_the_width_of_the_alternative() {
const N: usize = 400;
const DESIGN_WIDTH: usize = 6;
const DECAY: f64 = 0.55;
let column = |row: usize, index: usize| {
let x = row as f64 / N as f64;
DECAY.powi(index as i32) * (std::f64::consts::PI * (index + 1) as f64 * x).cos()
};
for width in [12usize, 18, 24] {
let mut design = Array2::<f64>::zeros((N, DESIGN_WIDTH));
let mut enrichment = Array2::<f64>::zeros((N, width));
let mut y = Array1::<f64>::zeros(N);
let mut rng = Lcg(2_789_2_788);
for row in 0..N {
for index in 0..DESIGN_WIDTH {
design[(row, index)] = column(row, index);
}
for index in 0..width {
enrichment[(row, index)] = column(row, index);
}
y[row] = design[(row, 0)] + 0.1 * rng.next_normal();
}
let out =
basis_adequacy_score_test(GaussianHarness::new(design, enrichment, y, 0.0).input())
.unwrap_or_else(|| panic!("width {width}: no verdict at all"));
assert_eq!(
out.rank,
width - DESIGN_WIDTH,
"width {width}: the design spans exactly {DESIGN_WIDTH} of the \
alternative's directions, so {} must stay estimable",
width - DESIGN_WIDTH
);
}
}
#[test]
fn fine_residual_subspace_is_not_ranked_against_the_absorbed_raw_head() {
const N: usize = 256;
const DESIGN_WIDTH: usize = 4;
const TAIL_WIDTH: usize = 8;
const TAIL_SCALE: f64 = 1.0e-8;
let mode = |row: usize, index: usize| {
let angle = std::f64::consts::PI * (row as f64 + 0.5) * index as f64 / N as f64;
angle.cos()
};
let mut design = Array2::<f64>::zeros((N, DESIGN_WIDTH));
let mut enrichment = Array2::<f64>::zeros((N, DESIGN_WIDTH + TAIL_WIDTH));
let mut y = Array1::<f64>::zeros(N);
let mut rng = Lcg(2_788_2_789_2_788);
for row in 0..N {
for index in 0..DESIGN_WIDTH {
let value = mode(row, index);
design[(row, index)] = value;
enrichment[(row, index)] = value;
}
for tail in 0..TAIL_WIDTH {
enrichment[(row, DESIGN_WIDTH + tail)] =
TAIL_SCALE * mode(row, DESIGN_WIDTH + tail);
}
y[row] = 2.0 * enrichment[(row, DESIGN_WIDTH)] + 0.1 * rng.next_normal();
}
let raw = enrichment.t().dot(&enrichment);
let head = raw[(0, 0)];
let tail = raw[(DESIGN_WIDTH, DESIGN_WIDTH)];
assert!(
tail < head * (N as f64) * f64::EPSILON,
"fixture must sit below the obsolete raw-Gram floor: tail={tail:e}, head={head:e}"
);
let out =
basis_adequacy_score_test(GaussianHarness::new(design, enrichment, y, 0.0).input())
.expect("the well-conditioned residual tail supports a verdict");
assert_eq!(
out.rank, TAIL_WIDTH,
"every orthogonal tail mode is new resolution, irrespective of its scale"
);
}
#[test]
fn weighted_gram_matches_the_direct_product() {
let design = array![[1.0, 0.5], [1.0, -2.0], [1.0, 3.0], [1.0, 0.0]];
let weights = array![0.25, 2.0, 1.5, 0.0];
let gram = weighted_gram(design.view(), weights.view()).expect("finite inputs");
let mut expected = Array2::<f64>::zeros((2, 2));
for row in 0..4 {
for i in 0..2 {
for j in 0..2 {
expected[(i, j)] += weights[row] * design[(row, i)] * design[(row, j)];
}
}
}
for i in 0..2 {
for j in 0..2 {
assert!((gram[(i, j)] - expected[(i, j)]).abs() < 1e-12);
}
}
assert!(weighted_gram(design.view(), array![1.0, 2.0].view()).is_none());
}
fn select(matrix: &Array2<f64>, rows: &[usize]) -> Array2<f64> {
let mut out = Array2::<f64>::zeros((rows.len(), matrix.ncols()));
for (local, &row) in rows.iter().enumerate() {
out.row_mut(local).assign(&matrix.row(row));
}
out
}
#[test]
fn degenerate_inputs_refuse() {
let design = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
let enrichment = array![[0.0], [1.0], [4.0]];
let y = array![0.1, 0.2, 0.3];
let harness = GaussianHarness::new(design, enrichment, y, 1.0);
let mut bad_dispersion = harness.input();
bad_dispersion.dispersion = 0.0;
assert_eq!(basis_adequacy_score_test(bad_dispersion), None);
let mismatched = Array2::<f64>::zeros((2, 1));
let mut bad_rows = harness.input();
bad_rows.enrichment = mismatched.view();
assert_eq!(basis_adequacy_score_test(bad_rows), None);
let mut estimated_without_df = harness.input();
estimated_without_df.scale = SmoothTestScale::Estimated;
estimated_without_df.residual_df = None;
assert_eq!(basis_adequacy_score_test(estimated_without_df), None);
}
}