use std::sync::Arc;
use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
use gam_problem::RowMetric;
use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh};
use faer::Side;
const ALTERNATION_SWEEPS: usize = 8;
const ACTIVITY_SCALE_BINS: usize = 8;
const DIAGONAL_REL_FLOOR: f64 = 1e-6;
const SCALE_REL_FLOOR: f64 = 1e-4;
#[derive(Clone, Debug)]
pub struct StructuredResidualModel {
p: usize,
factor_rank: usize,
lambda: Array2<f64>,
diagonal: Array1<f64>,
row_scale: Array1<f64>,
log_evidence: f64,
}
pub struct ResidualFactorInput<'a> {
pub residuals: ArrayView2<'a, f64>,
pub activity: ArrayView1<'a, f64>,
pub max_factor_rank: usize,
}
#[derive(Clone, Debug)]
pub struct FactorPromotion {
pub direction: Array1<f64>,
pub energy: f64,
pub persistence_alignment: f64,
pub prev_column: usize,
}
impl StructuredResidualModel {
pub fn fit(input: ResidualFactorInput<'_>) -> Result<Self, String> {
let r = input.residuals;
let z = input.activity;
let n = r.nrows();
let p = r.ncols();
if n == 0 || p == 0 {
return Err(format!(
"StructuredResidualModel::fit: residuals must be non-empty; got ({n}, {p})"
));
}
if z.len() != n {
return Err(format!(
"StructuredResidualModel::fit: activity length {} != residual rows {n}",
z.len()
));
}
if !r.iter().all(|v| v.is_finite()) {
return Err("StructuredResidualModel::fit: residuals must be finite".to_string());
}
if !z.iter().all(|v| v.is_finite()) {
return Err("StructuredResidualModel::fit: activity must be finite".to_string());
}
let bins = ACTIVITY_SCALE_BINS.max(1);
let z_min = z.iter().copied().fold(f64::INFINITY, f64::min);
let z_max = z.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let z_span = z_max - z_min;
let row_bin: Vec<usize> = (0..n)
.map(|i| {
if z_span <= 0.0 {
0
} else {
let frac = (z[i] - z_min) / z_span;
let idx = (frac * bins as f64).floor() as isize;
idx.clamp(0, bins as isize - 1) as usize
}
})
.collect();
let max_rank = input.max_factor_rank.min(p.saturating_sub(1));
let mut best: Option<StructuredResidualModel> = None;
for rank in 0..=max_rank {
let model = Self::fit_fixed_rank(r, &row_bin, bins, rank)?;
let take = match &best {
None => true,
Some(b) => model.log_evidence > b.log_evidence,
};
if take {
best = Some(model);
}
}
best.ok_or_else(|| "StructuredResidualModel::fit: evidence ladder empty".to_string())
}
fn fit_fixed_rank(
r: ArrayView2<'_, f64>,
row_bin: &[usize],
bins: usize,
rank: usize,
) -> Result<Self, String> {
let n = r.nrows();
let p = r.ncols();
let mut total_var = 0.0_f64;
for i in 0..n {
for j in 0..p {
total_var += r[[i, j]] * r[[i, j]];
}
}
let mean_var = (total_var / (n as f64 * p as f64)).max(f64::MIN_POSITIVE);
let diag_floor = DIAGONAL_REL_FLOOR * mean_var;
let mut row_scale = Array1::<f64>::ones(n);
let mut bin_scale = Array1::<f64>::ones(bins);
let raw_diag = column_variances(r);
let mut diagonal = raw_diag.mapv(|v| v.max(diag_floor));
let mut lambda = Array2::<f64>::zeros((p, rank));
for _sweep in 0..ALTERNATION_SWEEPS {
let s = scaled_second_moment(r, &row_scale);
let (evals, evecs) = symmetric_eig_ascending(&s)?;
if rank > 0 {
for k in 0..rank {
let col = p - 1 - k;
let mean_diag = diagonal.iter().copied().sum::<f64>() / p as f64;
let energy = (evals[col] - mean_diag).max(0.0);
let amp = energy.sqrt();
for row in 0..p {
lambda[[row, k]] = amp * evecs[[row, col]];
}
}
}
for j in 0..p {
let mut factor_var = 0.0_f64;
for k in 0..rank {
factor_var += lambda[[j, k]] * lambda[[j, k]];
}
diagonal[j] = (raw_diag[j] - factor_var).max(diag_floor);
}
if rank > 0 {
let mut bin_num = Array1::<f64>::zeros(bins);
let mut bin_den = Array1::<f64>::zeros(bins);
let coords = factor_coordinates(&lambda, &diagonal, r)?;
for i in 0..n {
let mut energy = 0.0_f64;
for k in 0..rank {
energy += coords[[i, k]] * coords[[i, k]];
}
let b = row_bin[i];
bin_num[b] += energy;
bin_den[b] += rank as f64;
}
let global = {
let num: f64 = bin_num.iter().sum();
let den: f64 = bin_den.iter().sum();
if den > 0.0 { num / den } else { 1.0 }
};
for b in 0..bins {
bin_scale[b] = if bin_den[b] > 0.0 {
bin_num[b] / bin_den[b]
} else {
global
};
}
let scale_floor = SCALE_REL_FLOOR * global.max(f64::MIN_POSITIVE);
let smoothed = moving_average_3(&bin_scale);
for b in 0..bins {
bin_scale[b] = smoothed[b].max(scale_floor);
}
let mut bin_count = vec![0.0_f64; bins];
for &b in row_bin.iter() {
bin_count[b] += 1.0;
}
let mean_scale = (0..bins)
.map(|b| bin_count[b] * bin_scale[b])
.sum::<f64>()
/ n as f64;
if mean_scale > 0.0 {
bin_scale.mapv_inplace(|v| v / mean_scale);
}
for i in 0..n {
row_scale[i] = bin_scale[row_bin[i]];
}
}
}
let log_evidence = penalized_log_evidence(r, &lambda, &diagonal, &row_scale, rank);
let mut model = Self {
p,
factor_rank: rank,
lambda,
diagonal,
row_scale,
log_evidence,
};
if !model.is_finite() {
model.lambda = Array2::<f64>::zeros((p, rank));
model.row_scale = Array1::<f64>::ones(n);
}
Ok(model)
}
fn is_finite(&self) -> bool {
self.lambda.iter().all(|v| v.is_finite())
&& self.diagonal.iter().all(|v| v.is_finite() && *v > 0.0)
&& self.row_scale.iter().all(|v| v.is_finite() && *v > 0.0)
&& self.log_evidence.is_finite()
}
pub fn factor_rank(&self) -> usize {
self.factor_rank
}
pub fn factor(&self) -> ArrayView2<'_, f64> {
self.lambda.view()
}
pub fn diagonal(&self) -> ArrayView1<'_, f64> {
self.diagonal.view()
}
pub fn row_scale(&self) -> ArrayView1<'_, f64> {
self.row_scale.view()
}
pub fn log_evidence(&self) -> f64 {
self.log_evidence
}
pub fn promotion_candidates(
&self,
prev: Option<&StructuredResidualModel>,
align_min: f64,
energy_floor_mult: f64,
) -> Result<Vec<FactorPromotion>, String> {
if !align_min.is_finite() || !(0.0..=1.0).contains(&align_min) {
return Err(format!(
"StructuredResidualModel::promotion_candidates: align_min must be finite in [0,1]; got {align_min}"
));
}
if !energy_floor_mult.is_finite() || energy_floor_mult < 0.0 {
return Err(format!(
"StructuredResidualModel::promotion_candidates: energy_floor_mult must be finite and ≥ 0; got {energy_floor_mult}"
));
}
let prev = match prev {
Some(pv) => pv,
None => return Ok(Vec::new()),
};
if prev.p != self.p {
return Err(format!(
"StructuredResidualModel::promotion_candidates: prev output dim {} != {}",
prev.p, self.p
));
}
let r = self.factor_rank;
let prev_r = prev.factor_rank;
if r == 0 || prev_r == 0 {
return Ok(Vec::new());
}
let mean_d = self.diagonal.iter().copied().sum::<f64>() / self.p as f64;
let energy_floor = energy_floor_mult * mean_d;
let mut out: Vec<FactorPromotion> = Vec::new();
for j in 0..r {
let col = self.lambda.column(j);
let energy: f64 = col.iter().map(|v| v * v).sum();
if energy <= 0.0 || energy < energy_floor {
continue;
}
let norm = energy.sqrt();
let mut best_align = 0.0_f64;
let mut best_k = 0usize;
for k in 0..prev_r {
let pcol = prev.lambda.column(k);
let pnorm: f64 = pcol.iter().map(|v| v * v).sum::<f64>().sqrt();
if pnorm <= 0.0 {
continue;
}
let dot: f64 = col.iter().zip(pcol.iter()).map(|(a, b)| a * b).sum();
let cos_abs = (dot / (norm * pnorm)).abs();
if cos_abs > best_align {
best_align = cos_abs;
best_k = k;
}
}
if best_align >= align_min {
out.push(FactorPromotion {
direction: col.mapv(|v| v / norm),
energy,
persistence_alignment: best_align,
prev_column: best_k,
});
}
}
out.sort_by(|a, b| b.energy.total_cmp(&a.energy));
Ok(out)
}
pub fn row_metric(&self, n_rows: usize) -> Result<RowMetric, String> {
if n_rows != self.row_scale.len() {
return Err(format!(
"StructuredResidualModel::row_metric: requested {n_rows} rows but model has {}",
self.row_scale.len()
));
}
let p = self.p;
let r = self.factor_rank;
let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / self.diagonal[i]).collect();
let mut b = Array2::<f64>::zeros((p, r));
let mut bt = Array2::<f64>::zeros((r, p));
let mut m0 = Array2::<f64>::zeros((r, r));
if r > 0 {
for i in 0..p {
for k in 0..r {
b[[i, k]] = d_inv[i] * self.lambda[[i, k]];
}
}
for a in 0..r {
for bk in 0..r {
let mut acc = 0.0_f64;
for i in 0..p {
acc += self.lambda[[i, a]] * b[[i, bk]];
}
m0[[a, bk]] = acc;
}
}
for k in 0..r {
for i in 0..p {
bt[[k, i]] = b[[i, k]];
}
}
}
let mut u = Array2::<f64>::zeros((n_rows, p * p));
for row in 0..n_rows {
let precision = self.row_precision(&d_inv, &b, &bt, &m0, row)?;
let factor = lower_cholesky_psd(&precision)?;
for i in 0..p {
for k in 0..p {
u[[row, i * p + k]] = factor[[i, k]];
}
}
}
RowMetric::whitened_structured(Arc::new(u), p, p)
}
pub fn fit_row_metric(input: ResidualFactorInput<'_>) -> Result<RowMetric, String> {
let n = input.residuals.nrows();
Self::fit(input)?.row_metric(n)
}
pub fn row_metric_damped(
&self,
n_rows: usize,
gamma: f64,
prev: Option<&StructuredResidualModel>,
) -> Result<RowMetric, String> {
if n_rows != self.row_scale.len() {
return Err(format!(
"StructuredResidualModel::row_metric_damped: requested {n_rows} rows but model has {}",
self.row_scale.len()
));
}
if !gamma.is_finite() || !(0.0..=1.0).contains(&gamma) {
return Err(format!(
"StructuredResidualModel::row_metric_damped: gamma must be finite in [0,1]; got {gamma}"
));
}
if let Some(pv) = prev {
if pv.p != self.p {
return Err(format!(
"StructuredResidualModel::row_metric_damped: prev output dim {} != {}",
pv.p, self.p
));
}
if pv.row_scale.len() != n_rows {
return Err(format!(
"StructuredResidualModel::row_metric_damped: prev has {} rows but requested {n_rows}",
pv.row_scale.len()
));
}
}
if gamma == 1.0 {
return self.row_metric(n_rows);
}
if gamma == 0.0 {
return match prev {
Some(pv) => pv.row_metric(n_rows),
None => RowMetric::euclidean(n_rows, self.p),
};
}
let p = self.p;
let self_gram = outer_product(&self.lambda);
let prev_gram = prev.map(|pv| outer_product(&pv.lambda));
let mut u = Array2::<f64>::zeros((n_rows, p * p));
for row in 0..n_rows {
let c = self.row_scale[row].max(f64::MIN_POSITIVE);
let mut sigma = Array2::<f64>::zeros((p, p));
for a in 0..p {
for b in 0..p {
sigma[[a, b]] = gamma * c * self_gram[[a, b]];
}
sigma[[a, a]] += gamma * self.diagonal[a];
}
match prev {
Some(pv) => {
let cp = pv.row_scale[row].max(f64::MIN_POSITIVE);
let pg = prev_gram.as_ref().unwrap();
for a in 0..p {
for b in 0..p {
sigma[[a, b]] += (1.0 - gamma) * cp * pg[[a, b]];
}
sigma[[a, a]] += (1.0 - gamma) * pv.diagonal[a];
}
}
None => {
for a in 0..p {
sigma[[a, a]] += 1.0 - gamma;
}
}
}
for a in 0..p {
for b in (a + 1)..p {
let avg = 0.5 * (sigma[[a, b]] + sigma[[b, a]]);
sigma[[a, b]] = avg;
sigma[[b, a]] = avg;
}
}
let precision = invert_spd(&sigma)?;
let factor = lower_cholesky_psd(&precision)?;
for i in 0..p {
for k in 0..p {
u[[row, i * p + k]] = factor[[i, k]];
}
}
}
RowMetric::whitened_structured(Arc::new(u), p, p)
}
fn row_precision(
&self,
d_inv: &[f64],
b: &Array2<f64>,
bt: &Array2<f64>,
m0: &Array2<f64>,
row: usize,
) -> Result<Array2<f64>, String> {
let p = self.p;
let r = self.factor_rank;
let mut precision = Array2::<f64>::zeros((p, p));
for i in 0..p {
precision[[i, i]] = d_inv[i];
}
if r == 0 {
return Ok(precision);
}
let c = self.row_scale[row].max(f64::MIN_POSITIVE);
let mut cap = m0.clone();
for a in 0..r {
cap[[a, a]] += 1.0 / c;
}
let chol = cap
.cholesky(Side::Lower)
.map_err(|e| format!("StructuredResidualModel::row_precision capacitance: {e:?}"))?;
let x = chol.solve_mat(bt); for i in 0..p {
for j in 0..p {
let mut acc = 0.0_f64;
for k in 0..r {
acc += b[[i, k]] * x[[k, j]];
}
precision[[i, j]] -= acc;
}
}
for i in 0..p {
for j in (i + 1)..p {
let avg = 0.5 * (precision[[i, j]] + precision[[j, i]]);
precision[[i, j]] = avg;
precision[[j, i]] = avg;
}
}
Ok(precision)
}
}
fn outer_product(lambda: &Array2<f64>) -> Array2<f64> {
let p = lambda.nrows();
let r = lambda.ncols();
let mut g = Array2::<f64>::zeros((p, p));
for a in 0..p {
for b in 0..p {
let mut acc = 0.0_f64;
for k in 0..r {
acc += lambda[[a, k]] * lambda[[b, k]];
}
g[[a, b]] = acc;
}
}
g
}
fn invert_spd(a: &Array2<f64>) -> Result<Array2<f64>, String> {
let p = a.nrows();
let chol = a
.cholesky(Side::Lower)
.map_err(|e| format!("invert_spd: blended covariance not SPD: {e:?}"))?;
let mut inv = chol.solve_mat(&Array2::<f64>::eye(p));
for i in 0..p {
for j in (i + 1)..p {
let avg = 0.5 * (inv[[i, j]] + inv[[j, i]]);
inv[[i, j]] = avg;
inv[[j, i]] = avg;
}
}
Ok(inv)
}
fn column_variances(r: ArrayView2<'_, f64>) -> Array1<f64> {
let n = r.nrows();
let p = r.ncols();
let mut v = Array1::<f64>::zeros(p);
for j in 0..p {
let mut acc = 0.0_f64;
for i in 0..n {
acc += r[[i, j]] * r[[i, j]];
}
v[j] = acc / n as f64;
}
v
}
fn scaled_second_moment(r: ArrayView2<'_, f64>, row_scale: &Array1<f64>) -> Array2<f64> {
let n = r.nrows();
let p = r.ncols();
let mut s = Array2::<f64>::zeros((p, p));
for i in 0..n {
let w = 1.0 / row_scale[i].max(f64::MIN_POSITIVE);
for a in 0..p {
let ra = r[[i, a]];
for b in 0..p {
s[[a, b]] += w * ra * r[[i, b]];
}
}
}
s.mapv_inplace(|v| v / n as f64);
for a in 0..p {
for b in (a + 1)..p {
let avg = 0.5 * (s[[a, b]] + s[[b, a]]);
s[[a, b]] = avg;
s[[b, a]] = avg;
}
}
s
}
fn factor_coordinates(
lambda: &Array2<f64>,
diagonal: &Array1<f64>,
r: ArrayView2<'_, f64>,
) -> Result<Array2<f64>, String> {
let p = lambda.nrows();
let rank = lambda.ncols();
let n = r.nrows();
let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
let mut normal = Array2::<f64>::zeros((rank, rank));
for a in 0..rank {
for b in 0..rank {
let mut acc = 0.0_f64;
for i in 0..p {
acc += lambda[[i, a]] * d_inv[i] * lambda[[i, b]];
}
normal[[a, b]] = acc;
}
}
let trace = (0..rank).map(|k| normal[[k, k]]).sum::<f64>().max(1.0);
let ridge = 1e-10 * trace / rank.max(1) as f64;
for k in 0..rank {
normal[[k, k]] += ridge;
}
let chol = normal
.cholesky(Side::Lower)
.map_err(|e| format!("factor_coordinates normal solve: {e:?}"))?;
let mut coords = Array2::<f64>::zeros((n, rank));
let mut rhs = Array1::<f64>::zeros(rank);
for i in 0..n {
for a in 0..rank {
let mut acc = 0.0_f64;
for j in 0..p {
acc += lambda[[j, a]] * d_inv[j] * r[[i, j]];
}
rhs[a] = acc;
}
let gamma = chol.solvevec(&rhs);
for a in 0..rank {
coords[[i, a]] = gamma[a];
}
}
Ok(coords)
}
fn moving_average_3(v: &Array1<f64>) -> Array1<f64> {
let m = v.len();
let mut out = Array1::<f64>::zeros(m);
for i in 0..m {
let lo = i.saturating_sub(1);
let hi = (i + 1).min(m - 1);
let mut acc = 0.0_f64;
let mut cnt = 0.0_f64;
for j in lo..=hi {
acc += v[j];
cnt += 1.0;
}
out[i] = acc / cnt;
}
out
}
fn symmetric_eig_ascending(m: &Array2<f64>) -> Result<(Array1<f64>, Array2<f64>), String> {
m.eigh(Side::Lower)
.map_err(|e| format!("symmetric_eig: {e:?}"))
}
fn lower_cholesky_psd(a: &Array2<f64>) -> Result<Array2<f64>, String> {
if let Ok(chol) = a.cholesky(Side::Lower) {
return Ok(chol.lower_triangular());
}
let (evals, evecs) = symmetric_eig_ascending(a)?;
let max_ev = evals.iter().copied().fold(0.0_f64, f64::max).max(1.0);
let floor = 1e-10 * max_ev;
let p = a.nrows();
let mut sqrt = Array2::<f64>::zeros((p, p));
for i in 0..p {
for j in 0..p {
let mut acc = 0.0_f64;
for k in 0..p {
let ev = evals[k].max(floor);
acc += evecs[[i, k]] * ev.sqrt() * evecs[[j, k]];
}
sqrt[[i, j]] = acc;
}
}
sqrt.cholesky(Side::Lower)
.map(|c| c.lower_triangular())
.map_err(|e| format!("lower_cholesky_psd eigen-repair: {e:?}"))
}
fn penalized_log_evidence(
r: ArrayView2<'_, f64>,
lambda: &Array2<f64>,
diagonal: &Array1<f64>,
row_scale: &Array1<f64>,
rank: usize,
) -> f64 {
let n = r.nrows();
let p = r.ncols();
let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
let log_det_d: f64 = diagonal.iter().map(|&d| d.ln()).sum();
let two_pi_ln = (2.0 * std::f64::consts::PI).ln();
let mut m0 = Array2::<f64>::zeros((rank, rank));
if rank > 0 {
for a in 0..rank {
for b in 0..rank {
let mut acc = 0.0_f64;
for j in 0..p {
acc += lambda[[j, a]] * d_inv[j] * lambda[[j, b]];
}
m0[[a, b]] = acc;
}
}
}
let mut log_lik = 0.0_f64;
for i in 0..n {
let c = row_scale[i].max(f64::MIN_POSITIVE);
let mut quad = 0.0_f64;
for j in 0..p {
quad += r[[i, j]] * d_inv[j] * r[[i, j]];
}
let mut log_det = log_det_d;
if rank > 0 {
let mut m = m0.clone();
for a in 0..rank {
m[[a, a]] += 1.0 / c;
}
let mut w = Array1::<f64>::zeros(rank);
for a in 0..rank {
let mut wa = 0.0_f64;
for j in 0..p {
wa += lambda[[j, a]] * d_inv[j] * r[[i, j]];
}
w[a] = wa;
}
match m.cholesky(Side::Lower) {
Ok(chol) => {
let y = chol.solvevec(&w);
let mut wy = 0.0_f64;
for a in 0..rank {
wy += w[a] * y[a];
}
quad -= wy;
let diag = chol.diag();
let log_det_m: f64 = diag.iter().map(|&l| (l * l).ln()).sum();
log_det = log_det_d + log_det_m + rank as f64 * c.ln();
}
Err(_) => {
log_det = log_det_d;
}
}
}
log_lik += -0.5 * (log_det + quad + p as f64 * two_pi_ln);
}
let k_params = (p * rank + p + ACTIVITY_SCALE_BINS) as f64;
log_lik - 0.5 * k_params * (n.max(2) as f64).ln()
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::{Array1, Array2};
fn lcg_uniform(state: &mut u64) -> f64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((*state >> 11) as f64) / ((1u64 << 53) as f64)
}
fn lcg_normal(state: &mut u64) -> f64 {
let u1 = lcg_uniform(state).max(1e-12);
let u2 = lcg_uniform(state);
(-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
#[test]
fn evidence_ladder_prefers_planted_rank_one() {
let n = 5000usize;
let p = 4usize;
let lambda0 = ndarray::array![[1.5], [1.2], [-0.4], [0.3]];
let sigma_eps = 0.2_f64;
let slope = 1.3_f64;
let mut seed = 0xD1B54A32D192ED03_u64;
let mut residuals = Array2::<f64>::zeros((n, p));
let mut activity = Array1::<f64>::zeros(n);
for row in 0..n {
let z = (row as f64) / (n as f64 - 1.0);
activity[row] = z;
let amp = (slope * z).exp().sqrt();
let f = lcg_normal(&mut seed);
for i in 0..p {
residuals[[row, i]] = amp * lambda0[[i, 0]] * f + sigma_eps * lcg_normal(&mut seed);
}
}
let bins = ACTIVITY_SCALE_BINS.max(1);
let row_bin: Vec<usize> = (0..n)
.map(|i| {
let frac = activity[i];
(frac * bins as f64).floor().clamp(0.0, bins as f64 - 1.0) as usize
})
.collect();
let mut report = String::new();
let mut ev = Vec::new();
for rank in 0..=2usize {
let m = StructuredResidualModel::fit_fixed_rank(residuals.view(), &row_bin, bins, rank)
.expect("fixed-rank fit");
let k_params = (p * rank + p + ACTIVITY_SCALE_BINS) as f64;
let log_lik = m.log_evidence() + 0.5 * k_params * (n as f64).ln();
let col_norms: Vec<f64> = (0..rank)
.map(|k| {
m.factor()
.column(k)
.iter()
.map(|v| v * v)
.sum::<f64>()
.sqrt()
})
.collect();
report.push_str(&format!(
"rank {rank}: evidence={:.3} loglik={:.3} penalty={:.3} col_norms={:?} diag={:?}\n",
m.log_evidence(),
log_lik,
0.5 * k_params * (n as f64).ln(),
col_norms,
m.diagonal()
.iter()
.map(|v| (v * 1e4).round() / 1e4)
.collect::<Vec<_>>()
));
ev.push(m.log_evidence());
}
assert!(
ev[1] > ev[0] && ev[1] > ev[2],
"evidence ladder must prefer the planted rank 1; breakdown:\n{report}"
);
}
fn orthonormal_columns(m: ArrayView2<'_, f64>) -> Vec<Array1<f64>> {
let mut basis: Vec<Array1<f64>> = Vec::new();
for k in 0..m.ncols() {
let mut v = m.column(k).to_owned();
for q in &basis {
let c = v.dot(q);
v = &v - &(q * c);
}
let norm = v.dot(&v).sqrt();
if norm > 1e-10 {
basis.push(v / norm);
}
}
basis
}
fn projection_energy(v: &Array1<f64>, basis: &[Array1<f64>]) -> f64 {
basis.iter().map(|q| v.dot(q).powi(2)).sum()
}
#[test]
fn factor_recovers_planted_interference_subspace() {
let n = 6000usize;
let p = 6usize;
let raw1: Array1<f64> = ndarray::array![1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
let raw2: Array1<f64> = ndarray::array![1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
let v1 = &raw1 / raw1.dot(&raw1).sqrt();
let v2 = &raw2 / raw2.dot(&raw2).sqrt();
let (amp1, amp2) = (1.4_f64, 0.9_f64);
let sigma_eps = 0.15_f64;
let mut seed = 0x9E3779B97F4A7C15_u64;
let mut residuals = Array2::<f64>::zeros((n, p));
let activity = Array1::<f64>::zeros(n); for row in 0..n {
let f1 = amp1 * lcg_normal(&mut seed);
let f2 = amp2 * lcg_normal(&mut seed);
for i in 0..p {
residuals[[row, i]] = f1 * v1[i] + f2 * v2[i] + sigma_eps * lcg_normal(&mut seed);
}
}
let model = StructuredResidualModel::fit(ResidualFactorInput {
residuals: residuals.view(),
activity: activity.view(),
max_factor_rank: 4,
})
.expect("fit");
assert_eq!(
model.factor_rank(),
2,
"ladder must select the planted rank 2 (got {}, evidence {:.3})",
model.factor_rank(),
model.log_evidence()
);
let basis = orthonormal_columns(model.factor());
assert_eq!(basis.len(), 2, "fitted factor must span 2 directions");
let e1 = projection_energy(&v1, &basis);
let e2 = projection_energy(&v2, &basis);
assert!(
e1 > 0.95 && e2 > 0.95,
"planted directions must lie in range(Λ̂): cos² = ({e1:.4}, {e2:.4})"
);
}
#[test]
fn fitted_scale_recovers_planted_activity_law() {
let n = 6000usize;
let p = 4usize;
let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
let sigma_eps = 0.2_f64;
let slope = 1.3_f64;
let mut seed = 0xD1B54A32D192ED03_u64;
let mut residuals = Array2::<f64>::zeros((n, p));
let mut activity = Array1::<f64>::zeros(n);
for row in 0..n {
let z = (row as f64) / (n as f64 - 1.0);
activity[row] = z;
let amp = (slope * z).exp().sqrt();
let f = lcg_normal(&mut seed);
for i in 0..p {
residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
}
}
let model = StructuredResidualModel::fit(ResidualFactorInput {
residuals: residuals.view(),
activity: activity.view(),
max_factor_rank: 2,
})
.expect("fit");
assert_eq!(model.factor_rank(), 1, "planted rank is 1");
let fitted_log: Vec<f64> = model.row_scale().iter().map(|c| c.ln()).collect();
let planted_log: Vec<f64> = activity.iter().map(|z| slope * z).collect();
let mean_f = fitted_log.iter().sum::<f64>() / n as f64;
let mean_p = planted_log.iter().sum::<f64>() / n as f64;
let mut cov = 0.0_f64;
let mut var_f = 0.0_f64;
let mut var_p = 0.0_f64;
for i in 0..n {
let df = fitted_log[i] - mean_f;
let dp = planted_log[i] - mean_p;
cov += df * dp;
var_f += df * df;
var_p += dp * dp;
}
let corr = cov / (var_f.sqrt() * var_p.sqrt());
assert!(
corr > 0.9,
"fitted activity law must track the planted exp({slope}·z): corr = {corr:.4}"
);
let lo = model.row_scale()[n / 16]; let hi = model.row_scale()[n - 1 - n / 16]; let ratio = hi / lo;
assert!(
ratio > 1.8 && ratio < 5.5,
"fitted dynamic range {ratio:.3} must bracket the planted ≈3.1"
);
}
fn assign_bins(activity: &Array1<f64>, bins: usize) -> Vec<usize> {
let n = activity.len();
let z_min = activity.iter().copied().fold(f64::INFINITY, f64::min);
let z_max = activity.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let span = z_max - z_min;
(0..n)
.map(|i| {
if span <= 0.0 {
0
} else {
let frac = (activity[i] - z_min) / span;
(frac * bins as f64).floor().clamp(0.0, bins as f64 - 1.0) as usize
}
})
.collect()
}
#[test]
fn occupancy_weighted_scale_has_row_mean_one() {
let n = 4000usize;
let p = 4usize;
let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
let sigma_eps = 0.2_f64;
let slope = 2.0_f64;
let mut seed = 0xB5297A4D_u64 ^ 0x68E31DA4_u64;
let mut residuals = Array2::<f64>::zeros((n, p));
let mut activity = Array1::<f64>::zeros(n);
for row in 0..n {
let u = (row as f64) / (n as f64 - 1.0);
let z = u * u * u;
activity[row] = z;
let amp = (slope * z).exp().sqrt();
let f = lcg_normal(&mut seed);
for i in 0..p {
residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
}
}
let model = StructuredResidualModel::fit(ResidualFactorInput {
residuals: residuals.view(),
activity: activity.view(),
max_factor_rank: 2,
})
.expect("fit");
assert!(
model.factor_rank() >= 1,
"need a non-trivial scale law (rank ≥ 1) for this invariant to bite; got rank 0"
);
let row_mean = model.row_scale().iter().sum::<f64>() / n as f64;
assert!(
(row_mean - 1.0).abs() < 1e-9,
"occupancy-weighted row mean of c(z) must be 1; got {row_mean:.12}"
);
let bins = ACTIVITY_SCALE_BINS.max(1);
let row_bin = assign_bins(&activity, bins);
let mut counts = vec![0usize; bins];
let mut bin_val = vec![f64::NAN; bins];
for i in 0..n {
let b = row_bin[i];
counts[b] += 1;
bin_val[b] = model.row_scale()[i];
}
let occupied: Vec<usize> = (0..bins).filter(|&b| counts[b] > 0).collect();
let max_c = *counts.iter().max().unwrap();
let min_c = *counts.iter().filter(|&&c| c > 0).min().unwrap();
assert!(
max_c as f64 > 2.0 * min_c as f64,
"fixture must have uneven occupancy; counts = {counts:?}"
);
let uniform_mean =
occupied.iter().map(|&b| bin_val[b]).sum::<f64>() / occupied.len() as f64;
assert!(
(uniform_mean - 1.0).abs() > 0.1,
"bin-uniform mean must differ from 1 (proving occupancy weighting matters); \
got uniform_mean = {uniform_mean:.6}, row_mean = {row_mean:.6}"
);
}
fn naive_penalized_log_evidence(
r: ArrayView2<'_, f64>,
lambda: &Array2<f64>,
diagonal: &Array1<f64>,
row_scale: &Array1<f64>,
rank: usize,
) -> f64 {
let n = r.nrows();
let p = r.ncols();
let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
let log_det_d: f64 = diagonal.iter().map(|&d| d.ln()).sum();
let two_pi_ln = (2.0 * std::f64::consts::PI).ln();
let mut log_lik = 0.0_f64;
for i in 0..n {
let c = row_scale[i].max(f64::MIN_POSITIVE);
let mut quad = 0.0_f64;
for j in 0..p {
quad += r[[i, j]] * d_inv[j] * r[[i, j]];
}
let mut log_det = log_det_d;
if rank > 0 {
let mut m = Array2::<f64>::zeros((rank, rank));
let mut w = Array1::<f64>::zeros(rank);
for a in 0..rank {
let mut wa = 0.0_f64;
for j in 0..p {
wa += lambda[[j, a]] * d_inv[j] * r[[i, j]];
}
w[a] = wa;
for b in 0..rank {
let mut acc = 0.0_f64;
for j in 0..p {
acc += lambda[[j, a]] * d_inv[j] * lambda[[j, b]];
}
m[[a, b]] = acc;
}
m[[a, a]] += 1.0 / c;
}
match m.cholesky(Side::Lower) {
Ok(chol) => {
let y = chol.solvevec(&w);
let mut wy = 0.0_f64;
for a in 0..rank {
wy += w[a] * y[a];
}
quad -= wy;
let diag = chol.diag();
let log_det_m: f64 = diag.iter().map(|&l| (l * l).ln()).sum();
log_det = log_det_d + log_det_m + rank as f64 * c.ln();
}
Err(_) => {
log_det = log_det_d;
}
}
}
log_lik += -0.5 * (log_det + quad + p as f64 * two_pi_ln);
}
let k_params = (p * rank + p + ACTIVITY_SCALE_BINS) as f64;
log_lik - 0.5 * k_params * (n.max(2) as f64).ln()
}
fn naive_factor_coordinates(
lambda: &Array2<f64>,
diagonal: &Array1<f64>,
r: ArrayView2<'_, f64>,
) -> Array2<f64> {
let p = lambda.nrows();
let rank = lambda.ncols();
let n = r.nrows();
let d_inv: Vec<f64> = (0..p).map(|i| 1.0 / diagonal[i]).collect();
let mut coords = Array2::<f64>::zeros((n, rank));
for i in 0..n {
let mut normal = Array2::<f64>::zeros((rank, rank));
for a in 0..rank {
for b in 0..rank {
let mut acc = 0.0_f64;
for j in 0..p {
acc += lambda[[j, a]] * d_inv[j] * lambda[[j, b]];
}
normal[[a, b]] = acc;
}
}
let trace = (0..rank).map(|k| normal[[k, k]]).sum::<f64>().max(1.0);
let ridge = 1e-10 * trace / rank.max(1) as f64;
for k in 0..rank {
normal[[k, k]] += ridge;
}
let chol = normal.cholesky(Side::Lower).expect("naive normal solve");
let mut rhs = Array1::<f64>::zeros(rank);
for a in 0..rank {
let mut acc = 0.0_f64;
for j in 0..p {
acc += lambda[[j, a]] * d_inv[j] * r[[i, j]];
}
rhs[a] = acc;
}
let gamma = chol.solvevec(&rhs);
for a in 0..rank {
coords[[i, a]] = gamma[a];
}
}
coords
}
#[test]
fn hoisted_gram_matches_naive_per_row_rebuild() {
let n = 200usize;
let p = 5usize;
let rank = 2usize;
let mut seed = 0x243F6A8885A308D3_u64;
let mut lambda = Array2::<f64>::zeros((p, rank));
for i in 0..p {
for k in 0..rank {
lambda[[i, k]] = lcg_normal(&mut seed);
}
}
let mut diagonal = Array1::<f64>::zeros(p);
for j in 0..p {
diagonal[j] = 0.3 + lcg_uniform(&mut seed); }
let mut row_scale = Array1::<f64>::zeros(n);
for i in 0..n {
row_scale[i] = 0.5 + 1.5 * lcg_uniform(&mut seed); }
let mut residuals = Array2::<f64>::zeros((n, p));
for i in 0..n {
for j in 0..p {
residuals[[i, j]] = lcg_normal(&mut seed);
}
}
let ev_hoisted =
penalized_log_evidence(residuals.view(), &lambda, &diagonal, &row_scale, rank);
let ev_naive =
naive_penalized_log_evidence(residuals.view(), &lambda, &diagonal, &row_scale, rank);
assert!(
(ev_hoisted - ev_naive).abs() <= 1e-10 * (1.0 + ev_naive.abs()),
"hoisted log-evidence must equal naive rebuild: {ev_hoisted} vs {ev_naive}"
);
let coords_hoisted =
factor_coordinates(&lambda, &diagonal, residuals.view()).expect("coords");
let coords_naive = naive_factor_coordinates(&lambda, &diagonal, residuals.view());
let mut max_abs = 0.0_f64;
for i in 0..n {
for a in 0..rank {
max_abs = max_abs.max((coords_hoisted[[i, a]] - coords_naive[[i, a]]).abs());
}
}
assert!(
max_abs <= 1e-10,
"hoisted factor coordinates must equal naive rebuild; max |Δ| = {max_abs:e}"
);
}
#[test]
fn occupancy_scale_improves_second_moment_reconstruction() {
let n = 4000usize;
let p = 4usize;
let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
let sigma_eps = 0.2_f64;
let slope = 2.0_f64;
let bins = ACTIVITY_SCALE_BINS.max(1);
let mut seed = 0xCA62C1D6_u64 ^ 0x9B05688C_u64;
let mut residuals = Array2::<f64>::zeros((n, p));
let mut activity = Array1::<f64>::zeros(n);
let mut c_true = Array1::<f64>::zeros(n);
for row in 0..n {
let u = (row as f64) / (n as f64 - 1.0);
let z = u * u * u; activity[row] = z;
let c = (slope * z).exp();
c_true[row] = c;
let amp = c.sqrt();
let f = lcg_normal(&mut seed);
for i in 0..p {
residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
}
}
let mut t = Array2::<f64>::zeros((p, p));
for i in 0..n {
for a in 0..p {
for b in 0..p {
t[[a, b]] += residuals[[i, a]] * residuals[[i, b]];
}
}
}
t.mapv_inplace(|v| v / n as f64);
let raw_diag = column_variances(residuals.view());
let mean_var = raw_diag.iter().sum::<f64>() / p as f64;
let diag_floor = DIAGONAL_REL_FLOOR * mean_var.max(f64::MIN_POSITIVE);
let row_bin = assign_bins(&activity, bins);
let mut bin_sum = vec![0.0_f64; bins];
let mut bin_cnt = vec![0.0_f64; bins];
for i in 0..n {
bin_sum[row_bin[i]] += c_true[i];
bin_cnt[row_bin[i]] += 1.0;
}
let bin_raw: Vec<f64> = (0..bins)
.map(|b| if bin_cnt[b] > 0.0 { bin_sum[b] / bin_cnt[b] } else { 1.0 })
.collect();
let mean_occ = (0..bins).map(|b| bin_cnt[b] * bin_raw[b]).sum::<f64>() / n as f64;
let occupied: Vec<usize> = (0..bins).filter(|&b| bin_cnt[b] > 0.0).collect();
let mean_uni =
occupied.iter().map(|&b| bin_raw[b]).sum::<f64>() / occupied.len() as f64;
let row_scale_occ: Array1<f64> =
(0..n).map(|i| bin_raw[row_bin[i]] / mean_occ).collect();
let row_scale_uni: Array1<f64> =
(0..n).map(|i| bin_raw[row_bin[i]] / mean_uni).collect();
let extract_recon = |row_scale: &Array1<f64>| -> Array2<f64> {
let s = scaled_second_moment(residuals.view(), row_scale);
let (evals, evecs) = symmetric_eig_ascending(&s).expect("eig");
let mean_diag =
raw_diag.iter().map(|&v| v.max(diag_floor)).sum::<f64>() / p as f64;
let col = p - 1;
let amp = (evals[col] - mean_diag).max(0.0).sqrt();
let mut lam = Array1::<f64>::zeros(p);
for j in 0..p {
lam[j] = amp * evecs[[j, col]];
}
let mut recon = Array2::<f64>::zeros((p, p));
for a in 0..p {
for b in 0..p {
recon[[a, b]] = lam[a] * lam[b];
}
}
for j in 0..p {
let d = (raw_diag[j] - lam[j] * lam[j]).max(diag_floor);
recon[[j, j]] += d;
}
recon
};
let frob = |m: &Array2<f64>| -> f64 {
let mut acc = 0.0_f64;
for a in 0..p {
for b in 0..p {
let d = m[[a, b]] - t[[a, b]];
acc += d * d;
}
}
acc.sqrt()
};
let dist_occ = frob(&extract_recon(&row_scale_occ));
let dist_uni = frob(&extract_recon(&row_scale_uni));
assert!(
dist_occ < dist_uni,
"occupancy-weighted reconstruction must beat bin-uniform: \
‖·‖_F occ = {dist_occ:.6} vs uni = {dist_uni:.6}"
);
}
fn fit_small_model(seed0: u64, lambda0: &Array1<f64>) -> (usize, StructuredResidualModel) {
let n = 300usize;
let p = lambda0.len();
let sigma_eps = 0.25_f64;
let slope = 1.4_f64;
let mut seed = seed0;
let mut residuals = Array2::<f64>::zeros((n, p));
let mut activity = Array1::<f64>::zeros(n);
for row in 0..n {
let u = (row as f64) / (n as f64 - 1.0);
let z = u * u;
activity[row] = z;
let amp = (slope * z).exp().sqrt();
let f = lcg_normal(&mut seed);
for i in 0..p {
residuals[[row, i]] = amp * lambda0[i] * f + sigma_eps * lcg_normal(&mut seed);
}
}
let model = StructuredResidualModel::fit(ResidualFactorInput {
residuals: residuals.view(),
activity: activity.view(),
max_factor_rank: 2,
})
.expect("fit");
(n, model)
}
#[test]
fn row_metric_precision_matches_woodbury_over_fitted_scale() {
let lambda0 = ndarray::array![1.5, 1.2, -0.4, 0.3];
let (n, model) = fit_small_model(0x14057B7EF767814F_u64, &lambda0);
let p = 4usize;
assert!(model.factor_rank() >= 1, "need a factor for a non-trivial Σ_n");
let metric = model.row_metric(n).expect("row_metric");
assert!(
metric.whitens_likelihood(),
"WhitenedStructured metric must whiten the likelihood"
);
let rank = model.factor_rank();
let lam = model.factor();
let diag = model.diagonal();
let v: Array1<f64> = ndarray::array![0.7, -1.3, 0.4, 0.9];
for &row in &[0usize, n / 3, n / 2, n - 1] {
let c = model.row_scale()[row];
let mut sigma = Array2::<f64>::zeros((p, p));
for a in 0..p {
for b in 0..p {
let mut fac = 0.0_f64;
for k in 0..rank {
fac += lam[[a, k]] * lam[[b, k]];
}
sigma[[a, b]] = c * fac;
}
sigma[[a, a]] += diag[a];
}
let chol = sigma.cholesky(Side::Lower).expect("Σ_n PD");
let x = chol.solvevec(&v);
let mahal_dense: f64 = v.iter().zip(x.iter()).map(|(a, b)| a * b).sum();
let mahal_metric = metric.quad_form(row, v.view());
assert!(
(mahal_dense - mahal_metric).abs() <= 1e-8 * (1.0 + mahal_dense.abs()),
"row {row}: metric quad_form {mahal_metric} must equal dense vᵀΣ⁻¹v {mahal_dense}"
);
}
let (n2, model2) = fit_small_model(0x14057B7EF767814F_u64, &lambda0);
assert_eq!(n2, n);
let metric_again = model2.row_metric(n2).expect("row_metric again");
for &row in &[0usize, n / 2, n - 1] {
let q1 = metric.quad_form(row, v.view());
let q2 = metric_again.quad_form(row, v.view());
assert!(
(q1 - q2).abs() <= 1e-12 * (1.0 + q1.abs()),
"deterministic refit must match at row {row}: {q2} vs {q1}"
);
}
}
#[test]
fn row_metric_damped_endpoints() {
let lambda_a = ndarray::array![1.5, 1.2, -0.4, 0.3];
let lambda_b = ndarray::array![-0.6, 1.1, 0.9, -1.3];
let (n, model) = fit_small_model(0x51ED270B_u64 ^ 0xF3A5C7D1_u64, &lambda_a);
let (n_prev, prev) = fit_small_model(0x2545F491_u64 ^ 0x4F6CDD1D_u64, &lambda_b);
assert_eq!(n, n_prev);
let p = 4usize;
let v: Array1<f64> = ndarray::array![0.7, -1.3, 0.4, 0.9];
let base = model.row_metric(n).expect("row_metric");
for prev_opt in [None, Some(&prev)] {
let damped = model.row_metric_damped(n, 1.0, prev_opt).expect("damped γ=1");
for row in [0usize, n / 2, n - 1] {
for i in 0..p {
for k in 0..p {
assert_eq!(
damped.factor_entry(row, i, k),
base.factor_entry(row, i, k),
"γ=1 must be byte-identical to row_metric at ({row},{i},{k})"
);
}
}
}
}
let ident = model.row_metric_damped(n, 0.0, None).expect("damped γ=0 None");
let sumsq: f64 = v.iter().map(|x| x * x).sum();
for row in [0usize, n / 2, n - 1] {
let q = ident.quad_form(row, v.view());
assert!(
(q - sumsq).abs() <= 1e-12 * (1.0 + sumsq),
"γ=0/None must be the identity metric: quad_form {q} vs ‖v‖² {sumsq}"
);
}
let prev_metric = prev.row_metric(n).expect("prev row_metric");
let damped0 = model.row_metric_damped(n, 0.0, Some(&prev)).expect("damped γ=0 Some");
for row in [0usize, n / 2, n - 1] {
for i in 0..p {
for k in 0..p {
assert_eq!(
damped0.factor_entry(row, i, k),
prev_metric.factor_entry(row, i, k),
"γ=0/Some must be byte-identical to prev.row_metric at ({row},{i},{k})"
);
}
}
}
let mid = model.row_metric_damped(n, 0.5, Some(&prev)).expect("damped γ=0.5");
for row in [0usize, n / 2, n - 1] {
let q = mid.quad_form(row, v.view());
assert!(q.is_finite() && q > 0.0, "γ=0.5 metric must be SPD; got {q}");
}
assert!(model.row_metric_damped(n, 1.5, None).is_err());
assert!(model.row_metric_damped(n, -0.1, None).is_err());
assert!(model.row_metric_damped(n, f64::NAN, None).is_err());
}
#[test]
fn promotion_candidates_gates_on_persistence_and_energy() {
let lambda_a = ndarray::array![1.5, 1.2, -0.4, 0.3];
let lambda_b = ndarray::array![-0.6, 1.1, 0.9, -1.3];
let (_, prev) = fit_small_model(0xA1B2C3D4_u64 ^ 0x0F0F0F0F_u64, &lambda_a);
let (_, cur) = fit_small_model(0x5566778899AABBCC_u64, &lambda_a);
let (_, other) = fit_small_model(0x1122334455667788_u64, &lambda_b);
assert!(prev.factor_rank() >= 1 && cur.factor_rank() >= 1 && other.factor_rank() >= 1);
let cands = cur
.promotion_candidates(Some(&prev), 0.9, 1.0)
.expect("promotion_candidates");
assert!(
!cands.is_empty(),
"a persistent, energetic factor must yield a promotion candidate"
);
let top = &cands[0];
assert!(
top.persistence_alignment >= 0.9,
"top candidate must clear the alignment gate; got {}",
top.persistence_alignment
);
let la_norm = lambda_a.dot(&lambda_a).sqrt();
let la_unit = lambda_a.mapv(|v| v / la_norm);
let dir_cos = top.direction.dot(&la_unit).abs();
assert!(
dir_cos > 0.9,
"promoted direction must recover the planted factor; |cos| = {dir_cos:.4}"
);
assert!(
(top.direction.dot(&top.direction) - 1.0).abs() < 1e-10,
"promoted direction must be unit-norm"
);
assert!(top.energy > 0.0);
let cross = cur
.promotion_candidates(Some(&other), 0.9, 1.0)
.expect("promotion_candidates cross");
assert!(
cross.is_empty(),
"a non-persistent (unaligned) factor must not be promoted; got {} candidate(s)",
cross.len()
);
let floored = cur
.promotion_candidates(Some(&prev), 0.9, 1.0e6)
.expect("promotion_candidates floored");
assert!(
floored.is_empty(),
"energy floor must gate out factors below the noise-scaled threshold"
);
assert!(cur.promotion_candidates(None, 0.9, 1.0).unwrap().is_empty());
assert!(cur.promotion_candidates(Some(&prev), 1.5, 1.0).is_err());
assert!(cur.promotion_candidates(Some(&prev), -0.1, 1.0).is_err());
assert!(cur.promotion_candidates(Some(&prev), 0.9, -1.0).is_err());
assert!(cur.promotion_candidates(Some(&prev), f64::NAN, 1.0).is_err());
}
}