use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh, FaerSvd};
use ndarray::{Array2, ArrayView2};
use super::Side;
#[derive(Clone, Debug)]
pub struct ReconSpectrum {
pub mu: Vec<f64>,
pub edge: f64,
pub basis_edf: f64,
pub n_eff: f64,
}
impl ReconSpectrum {
pub fn rank_hard(&self) -> f64 {
self.mu.iter().filter(|&&m| m > self.edge).count() as f64
}
pub fn rank_soft(&self) -> f64 {
self.mu
.iter()
.map(|&m| if self.edge > 0.0 { m / (m + self.edge) } else { 1.0 })
.sum()
}
pub fn rank_charge(&self) -> f64 {
0.5 * self.rank_hard() * self.basis_edf * self.n_eff.max(1.0).ln()
}
pub fn wbic_charge(&self) -> f64 {
0.5 * self.rank_soft() * self.basis_edf * self.n_eff.max(1.0).ln()
}
pub fn learning_coefficient(&self) -> f64 {
0.5 * self.rank_soft() * self.basis_edf
}
}
pub fn recon_spectrum(
gram: &Array2<f64>,
decoder: &Array2<f64>,
n_eff: f64,
p_out: f64,
r_floor: f64,
lam_smooth: f64,
smooth_penalty: Option<&Array2<f64>>,
) -> Result<ReconSpectrum, String> {
let m = gram.nrows();
if m == 0 || !(n_eff > 0.0) {
return Ok(ReconSpectrum {
mu: Vec::new(),
edge: 0.0,
basis_edf: 0.0,
n_eff: n_eff.max(0.0),
});
}
let (evals, u) = gram
.eigh(Side::Lower)
.map_err(|e| format!("recon_spectrum: eigh(G): {e}"))?;
let mut scaled = u.t().dot(decoder);
let cols = scaled.ncols();
for i in 0..m {
let s = evals[i].max(0.0).sqrt();
for j in 0..cols {
scaled[[i, j]] *= s;
}
}
let sv = match scaled.svd(false, false) {
Ok((_, sv, _)) => sv,
Err(e) => return Err(format!("recon_spectrum: recon svd: {e}")),
};
let edge = r_floor * (1.0 + (p_out / n_eff).sqrt()).powi(2);
let mu: Vec<f64> = sv.iter().map(|&s| (s * s) / n_eff).collect();
let mut mmat = gram.clone();
if let Some(pen) = smooth_penalty {
if pen.dim() == (m, m) {
for i in 0..m {
for j in 0..m {
mmat[[i, j]] += lam_smooth * pen[[i, j]];
}
}
}
}
for i in 0..m {
mmat[[i, i]] += 1.0e-12;
}
let basis_edf = match mmat.cholesky(Side::Lower) {
Ok(factor) => {
let x = factor.solve_mat(gram);
(0..m).map(|i| x[[i, i]]).sum::<f64>().clamp(0.0, m as f64)
}
Err(_) => m as f64,
};
Ok(ReconSpectrum {
mu,
edge,
basis_edf,
n_eff,
})
}
#[derive(Clone, Debug)]
pub struct AuditRow {
pub name: String,
pub n: usize,
pub rank_hard: f64,
pub rank_soft: f64,
pub basis_edf: f64,
pub rank_charge: f64,
pub wbic_charge: f64,
pub overcharge: f64,
pub overcharge_frac: f64,
}
impl AuditRow {
pub fn from_spectrum(name: impl Into<String>, spec: &ReconSpectrum, n: usize) -> Self {
let rank_charge = spec.rank_charge();
let wbic_charge = spec.wbic_charge();
let overcharge = rank_charge - wbic_charge;
let overcharge_frac = if rank_charge.abs() > 0.0 {
overcharge / rank_charge
} else {
f64::NAN
};
Self {
name: name.into(),
n,
rank_hard: spec.rank_hard(),
rank_soft: spec.rank_soft(),
basis_edf: spec.basis_edf,
rank_charge,
wbic_charge,
overcharge,
overcharge_frac,
}
}
}
pub fn render_audit_table(rows: &[AuditRow]) -> String {
let mut out = String::new();
out.push_str(
"population n rank_hard rank_soft basis_edf C_rank C_wbic overcharge frac\n",
);
out.push_str(
"----------------------- --- --------- --------- --------- -------- -------- ---------- ------\n",
);
for r in rows {
out.push_str(&format!(
"{:<23} {:>3} {:>9.3} {:>9.3} {:>9.3} {:>8.3} {:>8.3} {:>10.3} {:>6.3}\n",
r.name,
r.n,
r.rank_hard,
r.rank_soft,
r.basis_edf,
r.rank_charge,
r.wbic_charge,
r.overcharge,
r.overcharge_frac,
));
}
out
}
pub fn direction_learning_coefficient(mu: f64, edge: f64) -> f64 {
if edge > 0.0 {
0.5 * mu / (mu + edge)
} else {
0.5
}
}
pub fn sampled_direction_learning_coefficient(
mu: f64,
edge: f64,
n_eff: f64,
r_floor: f64,
n: usize,
) -> f64 {
let ln_n = (n as f64).ln();
if !(ln_n > 0.0) || !(r_floor > 0.0) || !(n_eff > 0.0) {
return 0.0;
}
let beta = 1.0 / ln_n;
let g = n_eff * mu; let g_edge = n_eff * edge; let h = beta * g / r_floor; let tau = beta * g_edge / r_floor; let prec_post = h + tau;
if !(prec_post > 0.0) {
return 0.0;
}
let alpha_hat2 = ((mu - edge).max(0.0)) / mu.max(f64::MIN_POSITIVE);
let var = 1.0 / prec_post;
let m_post = h * 0.0_f64.max(alpha_hat2.sqrt()) / prec_post; let alpha_hat = alpha_hat2.sqrt();
let shift2 = (m_post - alpha_hat) * (m_post - alpha_hat);
let e_delta = 0.5 * (g / r_floor) * (var + shift2);
e_delta / ln_n
}
pub fn spectrum_from_fit(
data: ArrayView2<'_, f64>,
w: &[f64],
phi: &Array2<f64>,
r_floor: f64,
lam_smooth: f64,
smooth_penalty: Option<&Array2<f64>>,
) -> Result<ReconSpectrum, String> {
let (n, p) = data.dim();
let m = phi.ncols();
if phi.nrows() != n || w.len() != n {
return Err("spectrum_from_fit: shape mismatch".into());
}
let mut gram = Array2::<f64>::zeros((m, m));
let mut cross = Array2::<f64>::zeros((m, p));
let mut n_eff = 0.0_f64;
for i in 0..n {
let wi = w[i];
n_eff += wi;
for a in 0..m {
let pa = phi[[i, a]] * wi;
for b in a..m {
gram[[a, b]] += pa * phi[[i, b]];
}
for j in 0..p {
cross[[a, j]] += pa * data[[i, j]];
}
}
}
for a in 0..m {
for b in a..m {
let v = gram[[a, b]];
gram[[a, b]] = v;
gram[[b, a]] = v;
}
}
let mut reg = gram.clone();
for a in 0..m {
reg[[a, a]] += 1.0e-9;
}
let decoder = reg
.cholesky(Side::Lower)
.map_err(|e| format!("spectrum_from_fit: chol: {e}"))?
.solve_mat(&cross);
recon_spectrum(
&gram,
&decoder,
n_eff,
p as f64,
r_floor,
lam_smooth,
smooth_penalty,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn lcg(s: &mut u64) -> f64 {
*s = s
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((*s >> 11) as f64) / ((1u64 << 53) as f64)
}
fn lcg_normal(s: &mut u64) -> f64 {
let u1 = lcg(s).max(1e-12);
let u2 = lcg(s);
(-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
fn harmonic_phi(turns: &[f64], h: usize) -> Array2<f64> {
let n = turns.len();
let m = 1 + 2 * h;
Array2::from_shape_fn((n, m), |(i, c)| {
if c == 0 {
1.0
} else {
let k = (c + 1) / 2;
let ang = std::f64::consts::TAU * k as f64 * turns[i];
if c % 2 == 1 { ang.cos() } else { ang.sin() }
}
})
}
fn poly_phi(t: &[f64], deg: usize) -> Array2<f64> {
let n = t.len();
Array2::from_shape_fn((n, deg + 1), |(i, c)| t[i].powi(c as i32))
}
#[test]
fn spectrum_hard_count_matches_production_deff() {
let mut s = 0x0B1C_0001_u64;
let n = 800usize;
let p = 12usize;
let turns: Vec<f64> = (0..n).map(|_| lcg(&mut s)).collect();
let phi = harmonic_phi(&turns, 3);
let m = phi.ncols();
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
let a = std::f64::consts::TAU * turns[i];
data[[i, 0]] += a.cos();
data[[i, 1]] += a.sin();
for j in 0..p {
data[[i, j]] += 0.05 * lcg_normal(&mut s);
}
}
let w = vec![1.0_f64; n];
let r_floor = 0.05_f64 * 0.05;
let spec = spectrum_from_fit(data.view(), &w, &phi, r_floor, 0.0, None).unwrap();
let mut gram = Array2::<f64>::zeros((m, m));
let mut cross = Array2::<f64>::zeros((m, p));
for i in 0..n {
for a in 0..m {
for b in 0..m {
gram[[a, b]] += phi[[i, a]] * phi[[i, b]];
}
for j in 0..p {
cross[[a, j]] += phi[[i, a]] * data[[i, j]];
}
}
}
let mut reg = gram.clone();
for a in 0..m {
reg[[a, a]] += 1.0e-9;
}
let decoder = reg.cholesky(Side::Lower).unwrap().solve_mat(&cross);
let d_prod = super::super::construction::realised_rank_charge_dof(
&gram,
&decoder,
n as f64,
p as f64,
r_floor,
0.0,
None,
)
.unwrap();
let d_audit = spec.rank_hard() * spec.basis_edf;
eprintln!(
"[wbic parity] production d_eff={d_prod:.10} audit rank_hard·basis_edf={d_audit:.10}"
);
assert!(
(d_prod - d_audit).abs() < 1e-8,
"audit hard d_eff must match production: prod={d_prod} audit={d_audit}"
);
}
#[test]
fn sigmoid_matches_tempered_posterior_variance_term() {
let n = 800usize;
let n_eff = 800.0_f64;
let r_floor = 0.0025_f64;
let edge = r_floor * (1.0 + (12.0_f64 / n_eff).sqrt()).powi(2);
for &ratio in &[8.0_f64, 4.0, 2.0, 1.0, 0.5, 0.25] {
let mu = ratio * edge;
let closed = direction_learning_coefficient(mu, edge);
let sampled = sampled_direction_learning_coefficient(mu, edge, n_eff, r_floor, n);
eprintln!("[wbic sigmoid] μ/e={ratio:.2} closed={closed:.4} sampled≈{sampled:.4}");
let beta = 1.0 / (n as f64).ln();
let g = n_eff * mu;
let g_edge = n_eff * edge;
let h = beta * g / r_floor;
let tau = beta * g_edge / r_floor;
let var_term = 0.5 * (g / r_floor) * (1.0 / (h + tau)) / (n as f64).ln();
assert!(
(closed - var_term).abs() < 1e-9,
"closed sigmoid must equal the tempered variance term: closed={closed} var={var_term}"
);
}
}
#[test]
fn wbic_audit_disagreement_table() {
let mut rows: Vec<AuditRow> = Vec::new();
let n = 1200usize;
let p = 16usize;
{
let mut s = 0x1111_u64;
let t: Vec<f64> = (0..n).map(|_| 2.0 * lcg(&mut s) - 1.0).collect();
let phi = poly_phi(&t, 2);
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
data[[i, 0]] += 2.0 * t[i];
data[[i, 1]] += 0.5 * t[i];
for j in 0..p {
data[[i, j]] += 0.05 * lcg_normal(&mut s);
}
}
let w = vec![1.0_f64; n];
let spec = spectrum_from_fit(data.view(), &w, &phi, 0.0025, 0.0, None).unwrap();
rows.push(AuditRow::from_spectrum("line (regular)", &spec, n));
}
{
let mut s = 0x2222_u64;
let phi = Array2::<f64>::from_shape_fn((n, 3), |(i, c)| {
if i % 3 == c { 1.0 } else { 0.0 }
});
let centers = [[3.0, 0.0], [0.0, 3.0], [-3.0, -3.0]];
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
let c = i % 3;
data[[i, 0]] += centers[c][0];
data[[i, 1]] += centers[c][1];
for j in 0..p {
data[[i, j]] += 0.1 * lcg_normal(&mut s);
}
}
let w = vec![1.0_f64; n];
let spec = spectrum_from_fit(data.view(), &w, &phi, 0.01, 0.0, None).unwrap();
rows.push(AuditRow::from_spectrum("clusters (regular)", &spec, n));
}
{
let mut s = 0x3333_u64;
let turns: Vec<f64> = (0..n).map(|_| lcg(&mut s)).collect();
let phi = harmonic_phi(&turns, 3);
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
let a = std::f64::consts::TAU * turns[i];
data[[i, 0]] += a.cos();
data[[i, 1]] += a.sin();
for j in 0..p {
data[[i, j]] += 0.05 * lcg_normal(&mut s);
}
}
let w = vec![1.0_f64; n];
let spec = spectrum_from_fit(data.view(), &w, &phi, 0.0025, 0.0, None).unwrap();
rows.push(AuditRow::from_spectrum("circle clean (curved)", &spec, n));
}
{
let mut s = 0x4444_u64;
let turns: Vec<f64> = (0..n).map(|_| lcg(&mut s)).collect();
let phi = harmonic_phi(&turns, 3);
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
let a = std::f64::consts::TAU * turns[i];
data[[i, 0]] += 0.22 * a.cos();
data[[i, 1]] += 0.22 * a.sin();
for j in 0..p {
data[[i, j]] += 0.15 * lcg_normal(&mut s);
}
}
let w = vec![1.0_f64; n];
let spec = spectrum_from_fit(data.view(), &w, &phi, 0.15 * 0.15, 0.0, None).unwrap();
rows.push(AuditRow::from_spectrum("circle near-edge (singular)", &spec, n));
}
{
let mut s = 0x5555_u64;
let turns: Vec<f64> = (0..n).map(|_| lcg(&mut s)).collect();
let radii: Vec<f64> = (0..n).map(|_| lcg(&mut s).sqrt()).collect();
let phi = harmonic_phi(&turns, 3);
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
let a = std::f64::consts::TAU * turns[i];
data[[i, 0]] += 0.35 * radii[i] * a.cos();
data[[i, 1]] += 0.35 * radii[i] * a.sin();
for j in 0..p {
data[[i, j]] += 0.12 * lcg_normal(&mut s);
}
}
let w = vec![1.0_f64; n];
let spec = spectrum_from_fit(data.view(), &w, &phi, 0.12 * 0.12, 0.0, None).unwrap();
rows.push(AuditRow::from_spectrum("disk (curved)", &spec, n));
}
{
let mut s = 0x6666_u64;
let turns: Vec<f64> = (0..n).map(|_| lcg(&mut s)).collect();
let phi = harmonic_phi(&turns, 3);
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
for j in 0..p {
data[[i, j]] += 0.1 * lcg_normal(&mut s);
}
}
let w = vec![1.0_f64; n];
let spec = spectrum_from_fit(data.view(), &w, &phi, 0.01, 0.0, None).unwrap();
rows.push(AuditRow::from_spectrum("gaussian blend (null)", &spec, n));
}
eprintln!("\n{}", render_audit_table(&rows));
let get = |name: &str| rows.iter().find(|r| r.name == name).unwrap().clone();
let line = get("line (regular)");
let clusters = get("clusters (regular)");
let near = get("circle near-edge (singular)");
let disk = get("disk (curved)");
let blend = get("gaussian blend (null)");
assert!(
line.overcharge_frac.abs() < 0.05,
"regular line must show ~no disagreement; frac={:.3}",
line.overcharge_frac
);
assert!(
clusters.overcharge_frac.abs() < 0.05,
"regular clusters must show ~no disagreement; frac={:.3}",
clusters.overcharge_frac
);
assert!(
disk.overcharge > 0.0 && disk.overcharge_frac > 0.15,
"curved disk must be OVER-charged (C_rank > C_wbic) by a clear fraction; \
overcharge={:.3} frac={:.3}",
disk.overcharge,
disk.overcharge_frac
);
assert!(
disk.overcharge_frac > line.overcharge_frac + 0.1
&& disk.overcharge_frac > clusters.overcharge_frac + 0.1,
"singular over-charge fraction ({:.3}) must exceed the regular atoms' \
(line {:.3}, clusters {:.3}) by a clear margin",
disk.overcharge_frac,
line.overcharge_frac,
clusters.overcharge_frac
);
assert!(
near.rank_hard == 0.0 && near.rank_soft > near.rank_hard,
"weak near-edge circle must show the hard count dropping below the soft \
count (under-charge): hard={:.3} soft={:.3}",
near.rank_hard,
near.rank_soft
);
assert!(
blend.rank_hard == 0.0 && blend.rank_soft < 0.3,
"gaussian blend must price ~0: rank_hard={:.3} rank_soft={:.3}",
blend.rank_hard,
blend.rank_soft
);
}
#[test]
fn rank_charge_is_inert_row_invariant() {
let mut s = 0x7777_u64;
let n = 400usize;
let p = 12usize;
let turns: Vec<f64> = (0..n).map(|_| lcg(&mut s)).collect();
let phi = harmonic_phi(&turns, 3);
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
let a = std::f64::consts::TAU * turns[i];
data[[i, 0]] += a.cos();
data[[i, 1]] += a.sin();
for j in 0..p {
data[[i, j]] += 0.05 * lcg_normal(&mut s);
}
}
let w_on = vec![1.0_f64; n];
let spec_before = spectrum_from_fit(data.view(), &w_on, &phi, 0.0025, 0.0, None).unwrap();
let m_extra = 600usize;
let n_aug = n + m_extra;
let mut turns_aug = turns.clone();
let mut data_aug = Array2::<f64>::zeros((n_aug, p));
data_aug.slice_mut(ndarray::s![0..n, ..]).assign(&data);
for _ in 0..m_extra {
turns_aug.push(lcg(&mut s)); }
for i in n..n_aug {
for j in 0..p {
data_aug[[i, j]] = lcg_normal(&mut s);
}
}
let phi_aug = harmonic_phi(&turns_aug, 3);
let mut w_aug = vec![1.0_f64; n];
w_aug.extend(std::iter::repeat(0.0_f64).take(m_extra));
let spec_after =
spectrum_from_fit(data_aug.view(), &w_aug, &phi_aug, 0.0025, 0.0, None).unwrap();
assert_eq!(
spec_before.rank_charge(),
spec_after.rank_charge(),
"inert (gate-off) rows must not change the rank charge: before={} after={}",
spec_before.rank_charge(),
spec_after.rank_charge()
);
assert_eq!(spec_before.n_eff, spec_after.n_eff);
assert!(spec_after.rank_hard() > 0.0, "fixture must have a real above-edge atom");
let old_before = 0.5 * spec_before.rank_hard() * spec_before.basis_edf * (n as f64).ln();
let old_after = 0.5 * spec_after.rank_hard() * spec_after.basis_edf * (n_aug as f64).ln();
assert!(
old_after > old_before + 1e-6,
"the OLD global-n scale WOULD have inflated the charge on inert rows \
(old_before={old_before:.4} old_after={old_after:.4}); the fix removes exactly this"
);
}
#[test]
fn rank_charge_equals_half_deff_ln_neff() {
let spec = ReconSpectrum {
mu: vec![10.0, 0.01],
edge: 1.0,
basis_edf: 3.0,
n_eff: 50.0,
};
assert_eq!(spec.rank_hard(), 1.0);
let d_eff = spec.rank_hard() * spec.basis_edf; let expected = 0.5 * d_eff * (50.0_f64).ln();
assert!(
(spec.rank_charge() - expected).abs() < 1e-12,
"rank charge must be ½·d_eff·ln(N_eff)={expected}, got {}",
spec.rank_charge()
);
let global = 0.5 * d_eff * (5000.0_f64).ln();
assert!(
(spec.rank_charge() - global).abs() > 1.0,
"charge must use N_eff (50), not a global n (5000)"
);
}
#[test]
fn soft_ledger_reduces_to_hard_away_from_edge_and_undercuts_near_it() {
let n = 1200usize;
let p = 16usize;
let mut s = 0xA1A1_u64;
let turns: Vec<f64> = (0..n).map(|_| lcg(&mut s)).collect();
let phi = harmonic_phi(&turns, 3);
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
let a = std::f64::consts::TAU * turns[i];
data[[i, 0]] += a.cos();
data[[i, 1]] += a.sin();
for j in 0..p {
data[[i, j]] += 0.05 * lcg_normal(&mut s);
}
}
let w = vec![1.0_f64; n];
let clean = spectrum_from_fit(data.view(), &w, &phi, 0.0025, 0.0, None).unwrap();
assert!(clean.rank_charge() > 0.0);
let clean_ratio = clean.wbic_charge() / clean.rank_charge();
assert!(
(clean_ratio - 1.0).abs() < 0.05,
"clean circle: soft ledger must reduce to the hard charge away from the edge \
(ratio soft/hard={clean_ratio:.3})"
);
let mut s = 0xB2B2_u64;
let turns: Vec<f64> = (0..n).map(|_| lcg(&mut s)).collect();
let radii: Vec<f64> = (0..n).map(|_| lcg(&mut s).sqrt()).collect();
let phi = harmonic_phi(&turns, 3);
let mut data = Array2::<f64>::zeros((n, p));
for i in 0..n {
let a = std::f64::consts::TAU * turns[i];
data[[i, 0]] += 0.35 * radii[i] * a.cos();
data[[i, 1]] += 0.35 * radii[i] * a.sin();
for j in 0..p {
data[[i, j]] += 0.12 * lcg_normal(&mut s);
}
}
let disk = spectrum_from_fit(data.view(), &w, &phi, 0.12 * 0.12, 0.0, None).unwrap();
assert!(
disk.rank_hard() > 0.0 && disk.rank_soft() < disk.rank_hard(),
"disk fixture must have above-edge directions the tempered count discounts: \
hard={:.3} soft={:.3}",
disk.rank_hard(),
disk.rank_soft()
);
assert!(
disk.wbic_charge() < disk.rank_charge(),
"disk: soft ledger must undercut the hard charge near the edge \
(soft={:.4} hard={:.4})",
disk.wbic_charge(),
disk.rank_charge()
);
let disk_ratio = disk.wbic_charge() / disk.rank_charge();
assert!(
disk_ratio < clean_ratio - 0.1,
"near-edge soft/hard ratio ({disk_ratio:.3}) must be clearly below the \
far-from-edge ratio ({clean_ratio:.3})"
);
}
}