use super::*;
use crate::{
BinomialLink, Family, GroupIds, Grouping, GroupingRelation, ModelSpec, NegBinomialLink,
ReStructure, Sizing, StartValues,
};
pub(crate) const PIN_REL_OLS: f64 = 1e-9;
pub(crate) const PIN_REL_ITER: f64 = 1e-7;
pub(crate) fn assert_pinned(got: &[f64], want: &[f64], band: f64, what: &str) {
assert_eq!(got.len(), want.len(), "{what}: length");
let mut worst = (f64::NEG_INFINITY, 0usize);
for (i, (&g, &w)) in got.iter().zip(want).enumerate() {
let rel = (g - w).abs() / w.abs();
if rel > worst.0 {
worst = (rel, i);
}
}
let (rel, i) = worst;
assert!(
rel < band,
"{what}[{i}] = {} vs pinned {} (rel {rel:.2e}, worst of {})",
got[i],
want[i],
got.len()
);
}
pub(crate) fn sim_clustered(csv: &str) -> (Vec<f64>, Vec<f64>, Vec<u32>, usize) {
let mut x = Vec::<f64>::new();
let mut y = Vec::<f64>::new();
let mut raw = Vec::<u32>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
raw.push(f[0].parse().unwrap());
x.extend_from_slice(&[
1.0,
f[1].parse().unwrap(),
f64::from(u32::from(f[2] == "b")),
]);
y.push(f[3].parse().unwrap());
}
let (ids, nc) = dense_ids(&raw);
(x, y, ids, nc)
}
#[test]
#[should_panic(expected = "n elements")]
fn weights_shape_still_asserted() {
let n = 4;
let x = vec![1.0f64; n];
let y = vec![1.0, 2.0, 3.0, 4.0];
let model = ModelSpec {
family: Family::Gaussian,
re: None,
};
let opts = FitOptions {
weights: Some(vec![1.0; n - 1]),
..FitOptions::default()
};
let _ = fit_cold(&x, &y, n, 1, &model, &GroupIds::default(), &opts);
}
#[test]
fn fit_rank_deficient_drops_and_matches_reduced() {
let n = 30;
let p = 3;
let mut st = 7u64;
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
for i in 0..n {
let x1 = lcg(&mut st);
x[i * p] = 1.0;
x[i * p + 1] = x1;
x[i * p + 2] = 1.0 + x1; y[i] = 0.5 + 0.4 * x1 + 0.3 * lcg(&mut st);
}
let model = ModelSpec {
family: Family::Gaussian,
re: None,
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&GroupIds::default(),
&FitOptions {
target_indices: vec![0, 1, 2],
..FitOptions::default()
},
);
assert!(f.converged(), "reduced OLS must converge");
assert_eq!(
f.aliased(),
vec![false, false, true],
"later collinear column dropped"
);
assert!(f.beta[2].is_nan(), "aliased β = NaN");
assert!(f.se[2].is_nan(), "aliased se = NaN");
let mut xr = vec![0.0f64; n * 2];
for i in 0..n {
xr[i * 2] = x[i * p];
xr[i * 2 + 1] = x[i * p + 1];
}
let fr = fit_cold(
&xr,
&y,
n,
2,
&model,
&GroupIds::default(),
&FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
},
);
assert!(
(f.beta[0] - fr.beta[0]).abs() < 1e-9,
"β0 {} vs reduced {}",
f.beta[0],
fr.beta[0]
);
assert!(
(f.beta[1] - fr.beta[1]).abs() < 1e-9,
"β1 {} vs reduced {}",
f.beta[1],
fr.beta[1]
);
}
#[test]
fn rank_deficient_random_slope_returns_nonconverged_instead_of_panicking() {
let (n_clusters, per) = (8usize, 12usize);
let n = n_clusters * per;
let p = 3usize;
let mut st = 29u64;
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
let mut ids = vec![0u32; n];
for i in 0..n {
ids[i] = (i % n_clusters) as u32;
let x1 = lcg(&mut st);
x[i * p] = 1.0;
x[i * p + 1] = x1;
x[i * p + 2] = 1.0 + x1; y[i] = 0.5 + 0.4 * x1 + 0.3 * lcg(&mut st);
}
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters {
n_clusters: n_clusters as u32,
},
slopes: vec![2], extra_groupings: vec![],
}),
};
let ids = GroupIds {
primary: ids,
extra: vec![],
};
let opts = FitOptions {
target_indices: vec![0, 1, 2],
..FitOptions::default()
};
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
fit_cold(&x, &y, n, p, &model, &ids, &opts)
}));
let f = match caught {
Ok(f) => f,
Err(e) => {
let msg = e
.downcast_ref::<String>()
.cloned()
.or_else(|| e.downcast_ref::<&str>().map(|s| (*s).to_string()))
.unwrap_or_else(|| "<non-string panic payload>".to_string());
panic!("fit_cold panicked on a rank-deficient random slope: {msg}");
}
};
assert!(
!f.converged(),
"an unfittable model must not report converged"
);
assert!(
f.beta.iter().all(|b| b.is_nan()) && f.se.iter().all(|s| s.is_nan()),
"β/se must be NaN-filled: beta {:?} se {:?}",
f.beta,
f.se
);
assert!(
f.vcov.iter().all(|row| row.iter().all(|v| v.is_nan())),
"vcov must be NaN-filled"
);
assert_eq!(
f.aliased(),
vec![false, false, true],
"the aliased column is still named"
);
assert_eq!(f.tau2.len(), 3, "tau2 keeps the unreduced θ width");
assert!(f.tau2.iter().all(|t| t.is_nan()), "tau2 NaN-filled");
assert!(f.dispersion.is_nan() && f.deviance.is_nan() && f.loglik.is_nan());
assert_eq!(f.df, 0, "no parameters were estimated");
assert!(
!f.singular(),
"singular is a fitted-boundary flag, not a failure flag"
);
assert!(f.varcorr.is_empty() && f.fitted.is_empty() && f.ranef.is_empty());
}
#[test]
fn fit_sim_collinear_drops_the_aliased_column() {
const REF_BETA: [f64; 3] = [0.9521742640860978, 0.7192032159906663, -0.43352501721286113];
const REF_SE: [f64; 3] = [0.05307754868563462, 0.0526001540491167, 0.05132117947475763];
let csv = include_str!("../../validation/data/simulated/sim_collinear.csv");
let mut y = Vec::<f64>::new();
let mut cols: Vec<[f64; 3]> = Vec::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap());
cols.push([
f[1].parse().unwrap(),
f[2].parse().unwrap(),
f[3].parse().unwrap(),
]);
}
let n = y.len();
let p = 4; let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0;
x[i * p + 1] = cols[i][0];
x[i * p + 2] = cols[i][1];
x[i * p + 3] = cols[i][2];
}
let model = ModelSpec {
family: Family::Gaussian,
re: None,
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&GroupIds::default(),
&FitOptions {
target_indices: vec![0, 1, 2, 3],
..FitOptions::default()
},
);
assert!(f.converged(), "reduced fit converges");
assert_eq!(
f.aliased(),
vec![false, false, false, true],
"x3 is the dependent column and the only one dropped"
);
assert!(f.beta[3].is_nan(), "aliased β = NaN");
assert!(f.se[3].is_nan(), "aliased se = NaN");
assert_pinned(&f.beta[..3], &REF_BETA, PIN_REL_OLS, "beta");
assert_pinned(&f.se[..3], &REF_SE, PIN_REL_OLS, "se");
}
pub(super) fn lcg(state: &mut u64) -> f64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(((*state >> 11) as f64) / ((1u64 << 53) as f64)) * 2.0 - 1.0
}
pub(super) fn lmm_hand_dataset() -> (Vec<f64>, Vec<f64>, usize, usize) {
let n = 48usize;
let p = 3;
let n_clusters = 6usize;
let mut st = 42u64;
let u_c: Vec<f64> = (0..n_clusters).map(|_| 0.6 * lcg(&mut st)).collect();
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
for i in 0..n {
let c = i % n_clusters;
let x1 = lcg(&mut st);
let x2 = lcg(&mut st);
x[i * p] = 1.0;
x[i * p + 1] = x1;
x[i * p + 2] = x2;
y[i] = 0.5 + 0.4 * x1 - 0.2 * x2 + u_c[c] + 0.8 * lcg(&mut st);
}
(x, y, n, p)
}
#[test]
fn theta_width_counts_vech_blocks() {
let re = ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 4 },
slopes: vec![1],
extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 3 },
slopes: vec![],
}],
};
assert_eq!(super::common::theta_width(Some(&re)), 3 + 1);
assert_eq!(super::common::theta_width(None), 0);
}
#[test]
fn varcorr_block_is_scaled_lambda_gram() {
let vech = super::common::varcorr_block(&[2.0, 0.5, 1.0], 2, 1.0, &[1.0, 1.0]);
assert_eq!(vech.len(), 3);
assert!((vech[0] - 4.0).abs() < 1e-12, "D00 {}", vech[0]);
assert!((vech[1] - 1.0).abs() < 1e-12, "D10 {}", vech[1]);
assert!((vech[2] - 1.25).abs() < 1e-12, "D11 {}", vech[2]);
let scaled = super::common::varcorr_block(&[2.0, 0.5, 1.0], 2, 3.0, &[1.0, 1.0]);
assert!((scaled[0] - 12.0).abs() < 1e-12);
assert!((scaled[2] - 3.75).abs() < 1e-12);
}
fn fit_with_varcorr(vech: Vec<f64>) -> Fit {
Fit {
beta: vec![],
se: vec![],
vcov: vec![],
tau2: vec![],
dispersion: 1.0,
diagnostics: crate::Diagnostics::from_flags(true, false, 0),
varcorr: vec![vech],
stddev_se: vec![],
n_eval: 0,
#[cfg(feature = "counters")]
counters: crate::counters::EvalCounters::new(),
deviance: f64::NAN,
loglik: f64::NAN,
df: 0,
reml: false,
fitted: vec![],
ranef: vec![],
ranef_levels: vec![],
}
}
#[test]
fn stddev_corr_q1_trivial() {
let f = fit_with_varcorr(vec![9.0]);
let (sd, corr) = f.stddev_corr(0);
assert_eq!(sd, vec![3.0]);
assert_eq!(corr, vec![vec![1.0]]);
}
#[test]
fn stddev_corr_q2_hand_math() {
let f = fit_with_varcorr(vec![4.0, 1.0, 1.25]);
let (sd, corr) = f.stddev_corr(0);
let sd1 = 1.25_f64.sqrt();
assert!((sd[0] - 2.0).abs() < 1e-12);
assert!((sd[1] - sd1).abs() < 1e-12);
assert_eq!(corr[0][0], 1.0);
assert_eq!(corr[1][1], 1.0);
let rho = 1.0 / (2.0 * sd1);
assert!((corr[0][1] - rho).abs() < 1e-12);
assert!((corr[1][0] - rho).abs() < 1e-12);
}
#[test]
fn stddev_corr_q3_hand_math() {
let f = fit_with_varcorr(vec![4.0, 1.0, 2.0, 9.0, 3.0, 16.0]);
let (sd, corr) = f.stddev_corr(0);
assert_eq!(sd, vec![2.0, 3.0, 4.0]);
#[allow(clippy::needless_range_loop)]
for i in 0..3 {
assert_eq!(corr[i][i], 1.0);
}
assert!((corr[0][1] - 1.0 / 6.0).abs() < 1e-12);
assert!((corr[1][0] - 1.0 / 6.0).abs() < 1e-12);
assert!((corr[0][2] - 0.25).abs() < 1e-12);
assert!((corr[2][0] - 0.25).abs() < 1e-12);
assert!((corr[1][2] - 0.25).abs() < 1e-12);
assert!((corr[2][1] - 0.25).abs() < 1e-12);
}
#[test]
fn assemble_varcorr_one_block_per_grouping() {
let g = crate::lmm::LmmGroupings::from_cluster_spec_ext(
&ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 4 },
slopes: vec![1],
extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 3 },
slopes: vec![],
}],
}),
},
16,
&[1],
&[],
);
let theta = [2.0, 0.5, 1.0, 0.7];
let vc = super::assemble_varcorr(&theta, &g, 1.0);
assert_eq!(vc.len(), 2);
assert_eq!(vc[0], vec![4.0, 1.0, 1.25]);
assert!((vc[1][0] - 0.49).abs() < 1e-12, "extra D {}", vc[1][0]);
}
#[test]
fn spec_sized_from_ids_derives_counts() {
let re = ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![],
extra_groupings: vec![
Grouping {
relation: GroupingRelation::Crossed { n_clusters: 1 },
slopes: vec![],
},
Grouping {
relation: GroupingRelation::NestedWithin { n_per_parent: 1 },
slopes: vec![],
},
],
};
let model = ModelSpec {
family: Family::Gaussian,
re: Some(re),
};
let ids = GroupIds {
primary: vec![0, 1, 2, 0, 1, 2], extra: vec![vec![0, 0, 1, 1, 2, 2], vec![0, 1, 2, 3, 4, 5]], };
let (sized, _ids, _perm) = super::spec_sized_from_ids(&model, &ids);
let sre = sized.re.unwrap();
assert_eq!(sre.sizing, Sizing::FixedClusters { n_clusters: 3 });
assert_eq!(
sre.extra_groupings[0].relation,
GroupingRelation::Crossed { n_clusters: 3 }
);
assert_eq!(
sre.extra_groupings[1].relation,
GroupingRelation::NestedWithin { n_per_parent: 2 }
);
}
#[test]
fn spec_sized_from_ids_nested_unbalanced_uses_true_max_per_parent() {
let re = ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 },
slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::NestedWithin { n_per_parent: 1 },
slopes: vec![],
}],
};
let model = ModelSpec {
family: Family::Gaussian,
re: Some(re),
};
let ids = GroupIds {
primary: vec![0, 1, 1, 2, 2, 2],
extra: vec![vec![0, 3, 4, 6, 7, 8]],
};
let (sized, _ids, _perm) = super::spec_sized_from_ids(&model, &ids);
let sre = sized.re.unwrap();
assert_eq!(sre.sizing, Sizing::FixedClusters { n_clusters: 3 });
assert_eq!(
sre.extra_groupings[0].relation,
GroupingRelation::NestedWithin { n_per_parent: 3 }
);
}
#[test]
fn spec_sized_from_ids_nested_unbalanced_first_parent_widest() {
let re = ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 },
slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::NestedWithin { n_per_parent: 1 },
slopes: vec![],
}],
};
let model = ModelSpec {
family: Family::Gaussian,
re: Some(re),
};
let ids = GroupIds {
primary: vec![0, 0, 0, 1, 1, 2],
extra: vec![vec![0, 1, 2, 3, 4, 5]],
};
let (sized, _ids, _perm) = super::spec_sized_from_ids(&model, &ids);
let sre = sized.re.unwrap();
assert_eq!(
sre.extra_groupings[0].relation,
GroupingRelation::NestedWithin { n_per_parent: 3 }
);
}
#[test]
fn fit_extra_grouping_q_too_large_routes_sparse() {
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 4 },
slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 4 },
slopes: vec![1, 2, 3, 4], }],
}),
};
assert!(matches!(classify_design(&model, 1), Solver::Sparse));
let n = 0;
let p = 5; let fit = fit_cold(
&[],
&[],
n,
p,
&model,
&GroupIds::from_sizing(model.re.as_ref().unwrap(), n),
&FitOptions {
target_indices: vec![1],
..FitOptions::default()
},
);
assert!(!fit.converged());
}
#[test]
fn fit_too_many_extra_groupings_routes_sparse() {
let extra_groupings: Vec<Grouping> = (0..7)
.map(|_| Grouping {
relation: GroupingRelation::Crossed { n_clusters: 2 },
slopes: vec![],
})
.collect();
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 2 },
slopes: vec![],
extra_groupings,
}),
};
assert!(matches!(classify_design(&model, 1), Solver::Sparse));
let (n, p) = (8, 1);
let x = vec![1.0f64; n * p];
let y = vec![0.0f64; n];
let ids = GroupIds {
primary: vec![0; n],
extra: vec![vec![0; n]; 7],
};
let fit = fit_cold(
&x,
&y,
n,
p,
&model,
&ids,
&FitOptions {
target_indices: vec![0],
..FitOptions::default()
},
);
assert_eq!(fit.beta.len(), p);
}
#[test]
fn fit_over_envelope_non_gaussian_never_panics() {
let families = [
Family::Binomial {
link: BinomialLink::Logit,
},
Family::Poisson {
link: crate::PoissonLink::Log,
},
Family::Gamma {
link: crate::GammaLink::Log,
},
Family::NegativeBinomial {
link: NegBinomialLink::Log,
},
];
for family in families {
let extra_groupings: Vec<Grouping> = (0..7)
.map(|_| Grouping {
relation: GroupingRelation::Crossed { n_clusters: 2 },
slopes: vec![],
})
.collect();
let model = ModelSpec {
family,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 2 },
slopes: vec![],
extra_groupings,
}),
};
assert!(matches!(classify_design(&model, 1), Solver::Sparse));
let (n, p) = (8, 1);
let x = vec![1.0f64; n * p];
let y = vec![1.0f64; n];
let ids = GroupIds {
primary: vec![0; n],
extra: vec![vec![0; n]; 7],
};
let fit = fit_cold(
&x,
&y,
n,
p,
&model,
&ids,
&FitOptions {
target_indices: vec![0],
..FitOptions::default()
},
);
assert_eq!(fit.beta.len(), p, "{family:?} over-count returns a Fit");
let model_w = ModelSpec {
family,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 4 },
slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 4 },
slopes: vec![1, 2, 3, 4],
}],
}),
};
assert!(matches!(classify_design(&model_w, 1), Solver::Sparse));
let (n, p) = (16, 5);
let mut st = 11u64;
let x: Vec<f64> = (0..n)
.flat_map(|_| {
let mut r = [0.0f64; 5];
r[0] = 1.0;
for v in r[1..].iter_mut() {
*v = lcg(&mut st);
}
r
})
.collect();
let y = vec![1.0f64; n];
let ids = GroupIds {
primary: (0..n as u32).map(|i| i % 4).collect(),
extra: vec![(0..n as u32).map(|i| (i / 4) % 4).collect()],
};
let fit = fit_cold(
&x,
&y,
n,
p,
&model_w,
&ids,
&FitOptions {
target_indices: vec![1],
..FitOptions::default()
},
);
assert_eq!(fit.beta.len(), p, "{family:?} over-width returns a Fit");
}
}
#[test]
fn fit_cold_equals_fit_warm_none() {
let (x, y, n, p) = lmm_hand_dataset();
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 6 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let ids = GroupIds::from_sizing(model.re.as_ref().unwrap(), n);
let opts = FitOptions {
target_indices: vec![1, 2],
..FitOptions::default()
};
let cold = fit_cold(&x, &y, n, p, &model, &ids, &opts);
let warm_none = fit_warm(&x, &y, n, p, &model, &ids, None, &opts);
let bits = |v: &[f64]| v.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
assert_eq!(bits(&cold.beta), bits(&warm_none.beta));
assert_eq!(bits(&cold.se), bits(&warm_none.se));
assert_eq!(bits(&cold.tau2), bits(&warm_none.tau2));
assert_eq!(cold.dispersion.to_bits(), warm_none.dispersion.to_bits());
assert_eq!(cold.converged(), warm_none.converged());
}
#[test]
fn fit_warm_start_reaches_cold_beta() {
let (x, y, n, p) = lmm_hand_dataset();
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 6 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let ids = GroupIds::from_sizing(model.re.as_ref().unwrap(), n);
let opts = FitOptions {
target_indices: vec![1, 2],
..FitOptions::default()
};
let cold = fit_cold(&x, &y, n, p, &model, &ids, &opts);
let start = StartValues {
beta: vec![0.0; p],
theta: vec![5.0],
};
let warm = fit_warm(&x, &y, n, p, &model, &ids, Some(&start), &opts);
assert!(
cold.converged() && warm.converged(),
"both fits must converge"
);
for j in [1usize, 2] {
let (a, b) = (cold.beta[j], warm.beta[j]);
let d = (a - b).abs();
assert!(
d <= 1e-7 || d <= 1e-6 * a.abs().max(b.abs()),
"LMM MLE must be start-independent: β[{j}] cold {a} vs warm {b}"
);
}
}
#[test]
fn fit_warm_theta_only_start_reaches_cold_beta() {
let (x, y, n, p) = lmm_hand_dataset();
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 6 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let ids = GroupIds::from_sizing(model.re.as_ref().unwrap(), n);
let opts = FitOptions {
target_indices: vec![1, 2],
..FitOptions::default()
};
let cold = fit_cold(&x, &y, n, p, &model, &ids, &opts);
let start = StartValues {
beta: vec![],
theta: vec![5.0],
};
let warm = fit_warm(&x, &y, n, p, &model, &ids, Some(&start), &opts);
assert!(
cold.converged() && warm.converged(),
"both fits must converge"
);
for j in [1usize, 2] {
let (a, b) = (cold.beta[j], warm.beta[j]);
let d = (a - b).abs();
assert!(
d <= 1e-7 || d <= 1e-6 * a.abs().max(b.abs()),
"LMM MLE must be start-independent: β[{j}] cold {a} vs warm {b}"
);
}
}
pub(super) fn dense_ids(raw: &[u32]) -> (Vec<u32>, usize) {
use std::collections::HashMap;
let mut map: HashMap<u32, u32> = HashMap::new();
let mut next = 0u32;
let ids: Vec<u32> = raw
.iter()
.map(|&r| {
*map.entry(r).or_insert_with(|| {
let v = next;
next += 1;
v
})
})
.collect();
(ids, next as usize)
}
pub(super) fn dense_str(raw: &[String]) -> (Vec<u32>, usize) {
use std::collections::HashMap;
let mut map: HashMap<String, u32> = HashMap::new();
let mut next = 0u32;
let ids: Vec<u32> = raw
.iter()
.map(|r| {
*map.entry(r.clone()).or_insert_with(|| {
let v = next;
next += 1;
v
})
})
.collect();
(ids, next as usize)
}
#[test]
fn classify_routes_at_the_cap_edge() {
let in_env = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 10 },
slopes: vec![],
extra_groupings: vec![],
}),
};
assert!(matches!(
super::classify_design_pub(&in_env, 1),
super::Solver::NoZ
));
let over = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 10 },
slopes: vec![],
extra_groupings: (0..(crate::consts::MAX_EXTRA_GROUPINGS + 1))
.map(|_| Grouping {
relation: GroupingRelation::Crossed { n_clusters: 4 },
slopes: vec![],
})
.collect(),
}),
};
assert!(matches!(
super::classify_design_pub(&over, 1),
super::Solver::Sparse
));
let wide = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 10 },
slopes: (1..=crate::consts::MAX_PRIMARY_Q as u32).collect(), extra_groupings: vec![],
}),
};
assert!(matches!(
super::classify_design_pub(&wide, 1),
super::Solver::Sparse
));
}
#[test]
fn classify_routes_many_crossed_levels_to_sparse() {
let cap = crate::consts::MAX_CROSSED_LEVELS as u32;
let spec = |extras: Vec<Grouping>| ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 10 },
slopes: vec![],
extra_groupings: extras,
}),
};
let crossed = |n_clusters: u32| Grouping {
relation: GroupingRelation::Crossed { n_clusters },
slopes: vec![],
};
let over = spec(vec![crossed(cap + 1)]);
assert!(matches!(
super::classify_design_pub(&over, 1),
super::Solver::Sparse
));
let sum_over = spec(vec![crossed(cap / 2 + 1), crossed(cap / 2 + 1)]);
assert!(matches!(
super::classify_design_pub(&sum_over, 1),
super::Solver::Sparse
));
let at_cap = spec(vec![crossed(cap)]);
assert!(matches!(
super::classify_design_pub(&at_cap, 1),
super::Solver::NoZ
));
let nested = spec(vec![Grouping {
relation: GroupingRelation::NestedWithin {
n_per_parent: cap + 1,
},
slopes: vec![],
}]);
assert!(matches!(
super::classify_design_pub(&nested, 1),
super::Solver::NoZ
));
}
#[test]
fn classify_routes_slope_extras_to_sparse_all_families() {
let spec = |family: Family, extra_slopes: Vec<u32>| ModelSpec {
family,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 10 },
slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 5 },
slopes: extra_slopes,
}],
}),
};
let g_slope = spec(Family::Gaussian, vec![1]);
assert!(matches!(
super::classify_design_pub(&g_slope, 1),
super::Solver::Sparse
));
let g_int = spec(Family::Gaussian, vec![]);
assert!(matches!(
super::classify_design_pub(&g_int, 1),
super::Solver::NoZ
));
let p_slope = spec(
Family::Poisson {
link: crate::PoissonLink::Log,
},
vec![1],
);
assert!(matches!(
super::classify_design_pub(&p_slope, 1),
super::Solver::Sparse
));
}
#[test]
fn classify_fixed_only_is_noz() {
let ols = ModelSpec {
family: Family::Gaussian,
re: None,
};
assert!(matches!(
super::classify_design_pub(&ols, 1),
super::Solver::NoZ
));
}
#[test]
fn fixed_only_fit_runs_zero_bobyqa_evals() {
let n = 24;
let p = 2;
let mut st = 3u64;
let mut x = vec![0.0f64; n * p];
let mut xv = vec![0.0f64; n];
for i in 0..n {
let x1 = lcg(&mut st);
x[i * p] = 1.0;
x[i * p + 1] = x1;
xv[i] = x1;
}
let families: [(Family, Vec<f64>); 5] = [
(
Family::Gaussian,
(0..n).map(|i| 0.5 + 0.4 * xv[i]).collect(),
),
(
Family::Binomial {
link: BinomialLink::Logit,
},
(0..n).map(|i| f64::from(u32::from(xv[i] > 0.0))).collect(),
),
(
Family::Poisson {
link: crate::PoissonLink::Log,
},
(0..n).map(|i| f64::from(1 + (i % 4) as u32)).collect(),
),
(
Family::Gamma {
link: crate::GammaLink::Log,
},
(0..n).map(|i| 1.0 + 0.5 * (xv[i] + 1.0)).collect(),
),
(
Family::NegativeBinomial {
link: NegBinomialLink::Log,
},
(0..n).map(|i| f64::from(1 + (i % 4) as u32)).collect(),
),
];
for (family, y) in families {
let model = ModelSpec { family, re: None };
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&GroupIds::default(),
&FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
},
);
assert_eq!(
f.n_eval, 0,
"{family:?} fixed-only fit must enter BOBYQA zero times"
);
}
}
fn assert_vcov_agrees_with_se(fit: &Fit, p: usize, ctx: &str) {
assert_eq!(fit.vcov.len(), p, "{ctx}: vcov is not p rows");
for row in &fit.vcov {
assert_eq!(row.len(), p, "{ctx}: vcov is not p×p");
}
for j in 0..p {
if fit.se[j].is_finite() {
assert!(
fit.vcov[j][j].is_finite(),
"{ctx}: finite se[{j}] alongside NaN vcov[{j}][{j}]"
);
let want = fit.se[j] * fit.se[j];
assert!(
(fit.vcov[j][j] - want).abs() <= 1e-9 * want.abs().max(1e-12),
"{ctx}: vcov[{j}][{j}] = {} vs se[{j}]² = {want}",
fit.vcov[j][j]
);
}
}
for i in 0..p {
for j in 0..p {
if fit.vcov[i][j].is_finite() || fit.vcov[j][i].is_finite() {
assert_eq!(fit.vcov[i][j], fit.vcov[j][i], "{ctx}: vcov not symmetric");
}
}
}
}
#[test]
fn vcov_diagonal_is_se_squared_on_every_path() {
let (x, y, n, p) = lmm_hand_dataset();
let all: Vec<u32> = (0..p as u32).collect();
let opts = FitOptions {
target_indices: all.clone(),
..FitOptions::default()
};
let ols = fit_cold(
&x,
&y,
n,
p,
&ModelSpec {
family: Family::Gaussian,
re: None,
},
&GroupIds::default(),
&opts,
);
assert!(ols.converged());
assert_vcov_agrees_with_se(&ols, p, "ols");
let ids = GroupIds {
primary: (0..n).map(|i| (i % 6) as u32).collect(),
extra: vec![],
};
let lmm_model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 6 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let lmm = fit_cold(&x, &y, n, p, &lmm_model, &ids, &opts);
assert!(lmm.converged());
assert_vcov_agrees_with_se(&lmm, p, "lmm");
let yb: Vec<f64> = y.iter().map(|&v| f64::from(v > 0.5)).collect();
let binom = Family::Binomial {
link: BinomialLink::Logit,
};
let glm = fit_cold(
&x,
&yb,
n,
p,
&ModelSpec {
family: binom,
re: None,
},
&GroupIds::default(),
&opts,
);
assert!(glm.converged());
assert_vcov_agrees_with_se(&glm, p, "glm");
for wald_se in [WaldSe::Hessian, WaldSe::Rx] {
let glmm = fit_cold(
&x,
&yb,
n,
p,
&ModelSpec {
family: binom,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 6 },
slopes: vec![],
extra_groupings: vec![],
}),
},
&ids,
&FitOptions {
target_indices: all.clone(),
wald_se,
..FitOptions::default()
},
);
assert!(glmm.converged(), "glmm {wald_se:?} did not converge");
assert_vcov_agrees_with_se(&glmm, p, &format!("glmm {wald_se:?}"));
}
}
#[test]
fn vcov_is_nan_outside_the_target_block() {
let (x, y, n, p) = lmm_hand_dataset();
let fit = fit_cold(
&x,
&y,
n,
p,
&ModelSpec {
family: Family::Gaussian,
re: None,
},
&GroupIds::default(),
&FitOptions {
target_indices: vec![2], ..FitOptions::default()
},
);
assert!(fit.converged());
assert!(fit.se[2].is_finite() && fit.vcov[2][2].is_finite());
assert!(fit.se[0].is_nan() && fit.se[1].is_nan());
for j in [0usize, 1] {
for i in 0..p {
assert!(
fit.vcov[i][j].is_nan() && fit.vcov[j][i].is_nan(),
"vcov must be NaN outside the target block at ({i},{j})"
);
}
}
assert_vcov_agrees_with_se(&fit, p, "targets subset");
}
#[test]
fn vcov_rows_are_nan_for_aliased_columns() {
let (x, y, n, _) = lmm_hand_dataset();
let p = 4;
let mut xa = vec![0.0f64; n * p];
for i in 0..n {
xa[i * p] = x[i * 3];
xa[i * p + 1] = x[i * 3 + 1];
xa[i * p + 2] = x[i * 3 + 2];
xa[i * p + 3] = x[i * 3 + 1]; }
let fit = fit_cold(
&xa,
&y,
n,
p,
&ModelSpec {
family: Family::Gaussian,
re: None,
},
&GroupIds::default(),
&FitOptions {
target_indices: (0..p as u32).collect(),
..FitOptions::default()
},
);
assert!(
fit.aliased()[3],
"duplicate column must be detected aliased"
);
for i in 0..p {
assert!(
fit.vcov[i][3].is_nan(),
"aliased column keeps a NaN vcov col"
);
assert!(
fit.vcov[3][i].is_nan(),
"aliased column keeps a NaN vcov row"
);
}
assert_vcov_agrees_with_se(&fit, p, "aliased");
}
#[test]
fn diagnostics_moved_fields_agree_through_both_paths() {
let (n, p) = (12usize, 3usize);
let mut st = 11u64;
let mut x = Vec::with_capacity(n * p);
let mut y = Vec::with_capacity(n);
for i in 0..n {
let a = lcg(&mut st);
let b = lcg(&mut st);
x.extend_from_slice(&[1.0, a, b]);
y.push(0.3 + 1.1 * a - 0.7 * b + 0.05 * ((i % 3) as f64 - 1.0));
}
let model = ModelSpec {
family: Family::Gaussian,
re: None,
};
let opts = FitOptions {
target_indices: (0..p as u32).collect(),
..FitOptions::default()
};
let fit = fit_cold(&x, &y, n, p, &model, &GroupIds::default(), &opts);
assert_eq!(fit.diagnostics.converged, fit.converged());
assert_eq!(fit.diagnostics.singular, fit.singular());
assert_eq!(fit.diagnostics.aliased, fit.aliased());
assert!(fit.converged() && !fit.singular());
assert_eq!(fit.aliased(), vec![false; p]);
let mut xd = Vec::with_capacity(n * p);
for i in 0..n {
let a = x[i * p + 1];
xd.extend_from_slice(&[1.0, a, a]);
}
let dup = fit_cold(&xd, &y, n, p, &model, &GroupIds::default(), &opts);
assert_eq!(dup.diagnostics.aliased, dup.aliased());
assert_eq!(dup.aliased(), vec![false, false, true]);
assert!(dup.converged(), "the reduced model fits");
}
#[test]
fn lmm_reports_kkt_and_boundary_score() {
let (xs, ys, ns, ps) = lmm_hand_dataset();
let ids_s: Vec<u32> = (0..ns).map(|i| (i % 6) as u32).collect();
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 6 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let opts = FitOptions {
target_indices: (0..ps as u32).collect(),
..FitOptions::default()
};
let interior = fit_cold(
&xs,
&ys,
ns,
ps,
&model,
&GroupIds {
primary: ids_s,
extra: vec![],
},
&opts,
);
assert_eq!(interior.diagnostics.boundary, Boundary::Interior);
assert!(interior.diagnostics.kkt_grad_norm.is_finite());
assert!(interior.diagnostics.boundary_score.is_empty());
assert!(
interior.stddev_se.is_empty(),
"the LMM route reports no stddev_se"
);
let n2 = 48usize;
let n_clusters2 = 6usize;
let mut st = 7u64;
let mut xs2 = vec![0.0f64; n2 * 2];
let mut ys2 = vec![0.0f64; n2];
let mut ids2 = vec![0u32; n2];
for i in 0..n2 {
ids2[i] = (i % n_clusters2) as u32;
let x1 = lcg(&mut st);
xs2[i * 2] = 1.0;
xs2[i * 2 + 1] = x1;
let e = if (i / n_clusters2) % 2 == 0 {
0.8
} else {
-0.8
};
ys2[i] = 0.5 + 0.4 * x1 + e;
}
let model2 = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters {
n_clusters: n_clusters2 as u32,
},
slopes: vec![],
extra_groupings: vec![],
}),
};
let ids2 = GroupIds {
primary: ids2,
extra: vec![],
};
let unrequested = fit_cold(
&xs2,
&ys2,
n2,
2,
&model2,
&ids2,
&FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
},
);
assert_eq!(unrequested.diagnostics.boundary, Boundary::AtBoundary);
assert!(unrequested.diagnostics.kkt_grad_norm.is_finite());
assert!(unrequested.diagnostics.boundary_score.is_empty());
let pinned = fit_cold(
&xs2,
&ys2,
n2,
2,
&model2,
&ids2,
&FitOptions {
target_indices: vec![0, 1],
boundary_score: true,
..FitOptions::default()
},
);
assert_eq!(pinned.diagnostics.boundary, Boundary::AtBoundary);
assert!(pinned.diagnostics.kkt_grad_norm.is_finite());
let mut seen = 0usize;
for (g, flags) in pinned.diagnostics.pinned.iter().enumerate() {
for (i, &p) in flags.iter().enumerate() {
if p {
seen += 1;
let s = pinned.diagnostics.boundary_score[g][i];
assert!(s.is_finite() && s > 0.0, "score[{g}][{i}] = {s}");
}
}
}
assert_eq!(seen, 1);
assert!(pinned.stddev_se.is_empty());
}
#[test]
fn lmm_boundary_score_reported_after_canonicalization() {
let n_clusters = 6usize;
let reps_per_half = 8usize;
let n = n_clusters * reps_per_half * 2;
let mut st = 11u64;
let mut x = vec![0.0f64; n * 2];
let mut y = vec![0.0f64; n];
let mut ids = vec![0u32; n];
let mut idx = 0usize;
for half in 0..2 {
for _ in 0..reps_per_half {
for c in 0..n_clusters {
let x1 = lcg(&mut st);
let slope_c = 0.5 * (c as f64 - 2.5); let intercept_e = if half == 0 { 0.8 } else { -0.8 }; x[idx * 2] = 1.0;
x[idx * 2 + 1] = x1;
ids[idx] = c as u32;
y[idx] = 0.5 + (0.4 + slope_c) * x1 + intercept_e + 0.002 * lcg(&mut st);
idx += 1;
}
}
}
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters {
n_clusters: n_clusters as u32,
},
slopes: vec![1],
extra_groupings: vec![],
}),
};
let ids = GroupIds {
primary: ids,
extra: vec![],
};
let start = StartValues {
beta: vec![],
theta: vec![0.05, 3.0, 0.3],
};
let fit = fit_warm(
&x,
&y,
n,
2,
&model,
&ids,
Some(&start),
&FitOptions {
target_indices: vec![0, 1],
boundary_score: true,
..FitOptions::default()
},
);
assert!(fit.converged(), "status = {:?}", fit.diagnostics.boundary);
assert_eq!(fit.diagnostics.boundary, Boundary::AtBoundary);
assert_eq!(
fit.diagnostics.pinned,
vec![vec![true, false]],
"pinned = {:?}, stddev = {:?}",
fit.diagnostics.pinned,
fit.stddev_corr(0).0
);
let s = fit.diagnostics.boundary_score[0][0];
assert!(
s.is_finite(),
"boundary_score for the pinned intercept must be reported on a \
canonical Λ (its column below the diagonal is zero), got {s}"
);
assert!(fit.diagnostics.boundary_score[0][1].is_nan());
let rel = |a: f64, b: f64| (a - b).abs() / b.abs().max(1.0);
assert!(
rel(fit.deviance, -2.017076489408452e1) < 1e-9,
"deviance = {}",
fit.deviance
);
for (&got, &want) in fit.varcorr[0]
.iter()
.zip([0.0, 0.0, 0.6679633931875338].iter())
{
assert!(rel(got, want) < 1e-9, "varcorr = {:?}", fit.varcorr[0]);
}
}
#[test]
fn diagnostics_boundary_reports_both_ends() {
let n = 48usize;
let n_clusters = 6usize;
let mut st = 7u64;
let mut x = vec![0.0f64; n * 2];
let mut y = vec![0.0f64; n];
let mut ids = vec![0u32; n];
for i in 0..n {
ids[i] = (i % n_clusters) as u32;
let x1 = lcg(&mut st);
x[i * 2] = 1.0;
x[i * 2 + 1] = x1;
let e = if (i / n_clusters) % 2 == 0 { 0.8 } else { -0.8 };
y[i] = 0.5 + 0.4 * x1 + e;
}
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters {
n_clusters: n_clusters as u32,
},
slopes: vec![],
extra_groupings: vec![],
}),
};
let opts = FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
};
let pinned = fit_cold(
&x,
&y,
n,
2,
&model,
&GroupIds {
primary: ids,
extra: vec![],
},
&opts,
);
assert!(pinned.converged(), "a boundary fit still converges");
assert_eq!(pinned.diagnostics.boundary, Boundary::AtBoundary);
assert!(pinned.singular());
let (xs, ys, ns, ps) = lmm_hand_dataset();
let ids_s: Vec<u32> = (0..ns).map(|i| (i % 6) as u32).collect();
let interior = fit_cold(
&xs,
&ys,
ns,
ps,
&ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 6 },
slopes: vec![],
extra_groupings: vec![],
}),
},
&GroupIds {
primary: ids_s,
extra: vec![],
},
&FitOptions {
target_indices: (0..ps as u32).collect(),
..FitOptions::default()
},
);
assert!(interior.converged());
assert_eq!(interior.diagnostics.boundary, Boundary::Interior);
assert!(interior.diagnostics.pinned.is_empty(), "nothing pinned");
}
#[test]
fn diagnostics_pinned_aligns_with_varcorr_blocks() {
let (nc, per) = (16usize, 16usize);
let n = nc * per;
let mut st = 5u64;
let u0: Vec<f64> = (0..nc).map(|_| 0.6 * lcg(&mut st)).collect();
let mut x = vec![0.0f64; n * 2];
let mut y = vec![0.0f64; n];
let mut ids = vec![0u32; n];
for (c, &uc) in u0.iter().enumerate() {
for k in 0..per {
let i = c * per + k;
ids[i] = c as u32;
let x1 = if k % 2 == 0 { 1.0 } else { -1.0 };
let e = if (k / 2) % 2 == 0 { 0.8 } else { -0.8 };
x[i * 2] = 1.0;
x[i * 2 + 1] = x1;
y[i] = 0.5 + 0.4 * x1 + uc + e;
}
}
let fit = fit_cold(
&x,
&y,
n,
2,
&ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters {
n_clusters: nc as u32,
},
slopes: vec![1],
extra_groupings: vec![],
}),
},
&GroupIds {
primary: ids,
extra: vec![],
},
&FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
},
);
assert!(fit.converged());
assert_eq!(fit.diagnostics.pinned.len(), fit.varcorr.len());
let (sd, _) = fit.stddev_corr(0);
assert_eq!(fit.diagnostics.pinned[0].len(), sd.len());
assert_eq!(
fit.diagnostics.pinned[0],
vec![false, true],
"the SLOPE component is the pinned one; stddev {sd:?}"
);
assert!(
sd[1] / sd[0] < 1e-6,
"pinned component's stddev must collapse: {sd:?}"
);
assert!(sd[0] > 0.0, "interior component's stddev is positive");
}
#[test]
fn diagnostics_ill_conditioned_note_through_fit_cold() {
let (n, p, split) = (60usize, 3usize, 40usize);
const WSMALL: f64 = 1e-11;
let mut x = Vec::with_capacity(n * p);
let mut y = Vec::with_capacity(n);
let mut w = Vec::with_capacity(n);
for i in 0..n {
let a = ((i * 13) % 17) as f64 - 8.0;
let delta = if i < split { 0.0 } else { 1.0 };
x.extend_from_slice(&[1.0, a, a + delta]);
y.push(0.5 + 1.3 * a + 0.477 * (a + delta) + ((i % 3) as f64 - 1.0));
w.push(if i < split { 1.0 } else { WSMALL });
}
let model = ModelSpec {
family: Family::Gaussian,
re: None,
};
let targets: Vec<u32> = (0..p as u32).collect();
let flagged = fit_cold(
&x,
&y,
n,
p,
&model,
&GroupIds::default(),
&FitOptions {
target_indices: targets.clone(),
weights: Some(w),
..FitOptions::default()
},
);
assert!(flagged.converged(), "fit-and-flag: the design is returned");
assert_eq!(flagged.aliased(), vec![false; p], "nothing was dropped");
assert_eq!(flagged.diagnostics.notes.len(), 1);
let Note::IllConditioned { columns, pivot } = &flagged.diagnostics.notes[0] else {
panic!(
"expected IllConditioned, got {:?}",
flagged.diagnostics.notes[0]
);
};
assert_eq!(columns, &vec![2u32], "the later column of the pair");
assert!(
*pivot < crate::ols::PIVOT_MIN,
"the note carries the measured ratio, got {pivot}"
);
let clean = fit_cold(
&x,
&y,
n,
p,
&model,
&GroupIds::default(),
&FitOptions {
target_indices: targets,
..FitOptions::default()
},
);
assert!(clean.converged());
assert!(
clean.diagnostics.notes.is_empty(),
"unweighted, the same design is well-conditioned"
);
assert!(clean.diagnostics.pinned.is_empty(), "no RE, nothing to pin");
}
#[cfg(feature = "formula")]
fn scale_spread_table(x_scale: f64) -> (crate::formula::Table, usize) {
use crate::formula::{Column, Table};
let n_groups = 5;
let per_group = 8;
let n = n_groups * per_group;
let mut y = Vec::with_capacity(n);
let mut x = Vec::with_capacity(n);
let mut g_labels = Vec::with_capacity(n);
for gi in 0..n_groups {
for j in 0..per_group {
let jitter = j as f64 - (per_group as f64 - 1.0) / 2.0;
let xv = x_scale + jitter;
x.push(xv);
y.push(1.0 + 0.1 * xv / x_scale + 0.05 * gi as f64);
g_labels.push(format!("g{gi}"));
}
}
let table = Table {
n,
columns: vec![
("y".to_string(), Column::Numeric(y)),
("x".to_string(), Column::Numeric(x)),
("g".to_string(), Column::factor_from_labels(&g_labels)),
],
};
(table, n)
}
#[cfg(feature = "formula")]
#[test]
fn re_design_scale_spread_note_fires_on_mismatched_slope_scale() {
let (table, _n) = scale_spread_table(1.0e4);
let lo = crate::formula::lower("y ~ x + (1 + x | g)", &table, Family::Gaussian).unwrap();
let spread: Vec<&Note> = lo
.notes
.iter()
.filter(|n| matches!(n, Note::ReDesignScaleSpread { .. }))
.collect();
assert_eq!(
spread.len(),
1,
"expected exactly one note, got {:?}",
lo.notes
);
match spread[0] {
Note::ReDesignScaleSpread { grouping, ratio } => {
assert_eq!(grouping, "g");
assert!(
(1.0e3..1.0e5).contains(ratio),
"ratio {ratio} not in the expected decade"
);
}
other => panic!("expected ReDesignScaleSpread, got {other:?}"),
}
let _ = fit_cold(&lo.x, &lo.y, lo.n, lo.p, &lo.model, &lo.ids, &lo.opts);
}
#[cfg(feature = "formula")]
#[test]
fn re_design_scale_spread_note_absent_on_well_scaled_design() {
let (table, _n) = scale_spread_table(4.0);
let lo = crate::formula::lower("y ~ x + (1 + x | g)", &table, Family::Gaussian).unwrap();
assert!(
!lo.notes
.iter()
.any(|n| matches!(n, Note::ReDesignScaleSpread { .. })),
"well-scaled design should not warn, got {:?}",
lo.notes
);
}