use pare::sensitivity::{analyze_redundancy, SensitivityRow};
use proptest::prelude::*;
use std::ops::RangeInclusive;
fn sensitivity_matrix(
m: RangeInclusive<usize>,
n: RangeInclusive<usize>,
) -> impl Strategy<Value = Vec<SensitivityRow>> {
(m, n).prop_flat_map(|(m, n)| {
prop::collection::vec(prop::collection::vec(-10.0..10.0_f64, n..=n), m..=m)
})
}
proptest! {
#[test]
fn eigenvalues_are_non_negative(
sens in sensitivity_matrix(2..=6, 3..=20),
) {
if let Some(a) = analyze_redundancy(&sens) {
for &ev in &a.eigenvalues {
prop_assert!(ev >= -1e-9, "negative eigenvalue: {ev}");
}
}
}
#[test]
fn eigenvalues_sorted_descending(
sens in sensitivity_matrix(2..=6, 3..=20),
) {
if let Some(a) = analyze_redundancy(&sens) {
for w in a.eigenvalues.windows(2) {
prop_assert!(w[0] + 1e-12 >= w[1], "not descending: {} then {}", w[0], w[1]);
}
}
}
#[test]
fn eigenvalue_sum_equals_trace(
sens in sensitivity_matrix(2..=6, 3..=20),
) {
if let Some(a) = analyze_redundancy(&sens) {
let ev_sum: f64 = a.eigenvalues.iter().sum();
let trace = a.trace();
let norm_sq: f64 = sens.iter()
.map(|row| row.iter().map(|&x| x * x).sum::<f64>())
.sum();
prop_assert!((ev_sum - trace).abs() < 1e-4 * trace.abs().max(1.0),
"ev_sum={ev_sum}, trace={trace}");
prop_assert!((trace - norm_sq).abs() < 1e-9 * norm_sq.abs().max(1.0),
"trace={trace}, norm_sq={norm_sq}");
}
}
#[test]
fn cosine_symmetric_and_bounded(
sens in sensitivity_matrix(2..=6, 3..=20),
) {
if let Some(a) = analyze_redundancy(&sens) {
let m = a.num_objectives;
for i in 0..m {
for j in 0..m {
let cij = a.cosine(i, j).unwrap();
let cji = a.cosine(j, i).unwrap();
prop_assert!((cij - cji).abs() < 1e-12, "asymmetric cosine [{i},{j}]");
prop_assert!((-1.0 - 1e-12..=1.0 + 1e-12).contains(&cij),
"cosine out of [-1,1]: {cij}");
}
let cii = a.cosine(i, i).unwrap();
prop_assert!(cii >= -1e-12, "diagonal cosine < 0: {cii}");
}
}
}
#[test]
fn effective_dimension_bounded(
sens in sensitivity_matrix(2..=8, 3..=15),
) {
if let Some(a) = analyze_redundancy(&sens) {
let m = a.num_objectives;
let n = a.num_design_points;
let eff = a.effective_dimension(0.01);
prop_assert!(eff <= m, "eff_dim={eff} > m={m}");
prop_assert!(eff <= n, "eff_dim={eff} > n={n}");
}
}
#[test]
fn pareto_bound_at_most_m_minus_1(
sens in sensitivity_matrix(2..=8, 3..=15),
) {
if let Some(a) = analyze_redundancy(&sens) {
let m = a.num_objectives;
let bound = a.pareto_dimension_bound(1e-6);
prop_assert!(bound < m, "pareto_bound={bound} >= m={m}");
}
}
#[test]
fn scaling_preserves_rank(
sens in sensitivity_matrix(2..=5, 3..=10),
scale in 0.5_f64..10.0,
row_idx in 0usize..5,
) {
if sens.is_empty() { return Ok(()); }
let m = sens.len();
let row_idx = row_idx % m;
let a1 = match analyze_redundancy(&sens) {
Some(a) => a,
None => return Ok(()),
};
let max_ev = a1.eigenvalues.iter().cloned().fold(0.0_f64, f64::max);
let min_ev = a1.eigenvalues.iter().cloned().fold(f64::MAX, f64::min);
if max_ev < 1e-15 || min_ev < max_ev * 1e-3 {
return Ok(());
}
let mut sens2 = sens.clone();
for v in &mut sens2[row_idx] {
*v *= scale;
}
let a2 = match analyze_redundancy(&sens2) {
Some(a) => a,
None => return Ok(()),
};
let relative_rank = |evs: &[f64]| -> usize {
let max = evs.iter().cloned().fold(0.0_f64, f64::max);
if max < 1e-15 { return 0; }
evs.iter().filter(|&&ev| ev > max * 1e-6).count()
};
let rank1 = relative_rank(&a1.eigenvalues);
let rank2 = relative_rank(&a2.eigenvalues);
prop_assert_eq!(rank1, rank2);
}
#[test]
fn permuting_rows_preserves_eigenvalues(
sens in sensitivity_matrix(2..=5, 3..=10),
) {
if sens.len() < 2 { return Ok(()); }
let a1 = match analyze_redundancy(&sens) {
Some(a) => a,
None => return Ok(()),
};
let mut sens2 = sens.clone();
sens2.reverse();
let a2 = match analyze_redundancy(&sens2) {
Some(a) => a,
None => return Ok(()),
};
for (e1, e2) in a1.eigenvalues.iter().zip(a2.eigenvalues.iter()) {
prop_assert!((e1 - e2).abs() < 1e-6 * e1.abs().max(1.0),
"eigenvalues differ after permutation: {e1} vs {e2}");
}
}
#[test]
fn variance_fraction_monotone(
sens in sensitivity_matrix(2..=6, 3..=15),
) {
if let Some(a) = analyze_redundancy(&sens) {
let m = a.num_objectives;
let mut prev = 0.0;
for k in 1..=m {
let frac = a.variance_fraction(k);
prop_assert!(frac + 1e-12 >= prev,
"variance fraction not monotone: k={k}, frac={frac}, prev={prev}");
prev = frac;
}
let full = a.variance_fraction(m);
prop_assert!((full - 1.0).abs() < 1e-9, "full variance fraction = {full}");
}
}
#[test]
fn duplicate_row_does_not_increase_dimension(
sens in sensitivity_matrix(2..=4, 3..=10),
row_idx in 0usize..4,
scale in 0.5_f64..5.0,
) {
if sens.is_empty() { return Ok(()); }
let m = sens.len();
let row_idx = row_idx % m;
let a1 = match analyze_redundancy(&sens) {
Some(a) => a,
None => return Ok(()),
};
let mut sens2 = sens.clone();
let dup: Vec<f64> = sens[row_idx].iter().map(|&v| v * scale).collect();
sens2.push(dup);
let a2 = match analyze_redundancy(&sens2) {
Some(a) => a,
None => return Ok(()),
};
let eff1 = a1.effective_dimension(0.01);
let eff2 = a2.effective_dimension(0.01);
prop_assert!(eff2 <= eff1 + 1,
"adding duplicate row increased eff_dim from {eff1} to {eff2}");
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 32, .. ProptestConfig::default() })]
#[test]
fn jacobi_handles_near_singular_gram(
base in prop::collection::vec(-10.0..10.0_f64, 5..=5),
perturbation in prop::collection::vec(-1e-6..1e-6_f64, 5..=5),
other in prop::collection::vec(-10.0..10.0_f64, 5..=5),
) {
let row1: Vec<f64> = base.iter().zip(perturbation.iter()).map(|(b, p)| b + p).collect();
let sens = vec![base, row1, other];
if let Some(a) = analyze_redundancy(&sens) {
for &ev in &a.eigenvalues {
prop_assert!(ev >= -1e-6, "negative eigenvalue from near-singular Gram: {ev}");
}
let ev_sum: f64 = a.eigenvalues.iter().sum();
let trace = a.trace();
prop_assert!((ev_sum - trace).abs() < 1e-3 * trace.abs().max(1.0),
"trace mismatch on near-singular: ev_sum={ev_sum}, trace={trace}");
let eff = a.effective_dimension(0.01);
prop_assert!(eff <= 2, "near-duplicate rows should give eff_dim <= 2, got {eff}");
}
}
}