use crate::sparse_dict::{SparseDictConfig, fit_sparse_dictionary};
use ndarray::ArrayView2;
#[derive(Clone, Copy, Debug)]
pub struct SpectrometerConfig {
pub k_min: usize,
pub n_doublings: usize,
pub dict: SparseDictConfig,
}
impl SpectrometerConfig {
pub fn new(k_min: usize, n_doublings: usize) -> Self {
Self {
k_min,
n_doublings,
dict: SparseDictConfig::default(),
}
}
}
impl Default for SpectrometerConfig {
fn default() -> Self {
Self::new(4, 6)
}
}
#[derive(Clone, Debug)]
pub struct SpectrometerReport {
pub rungs: Vec<(usize, f64)>,
pub n_rows: usize,
pub points_per_atom: Vec<f64>,
pub noise_floor: f64,
pub slope: f64,
pub slope_se: f64,
pub d_hat: f64,
pub d_hat_se: f64,
pub d_hat_full_ladder: f64,
pub d_hat_drop_last: f64,
pub stable_window_lo_k: usize,
pub stable_window_hi_k: usize,
pub stable_rung_count: usize,
pub min_points_per_atom: f64,
pub floor_saturated: bool,
pub small_nk_regime: bool,
}
const NORMAL_95_QUANTILE: f64 = 1.959_963_984_540_054;
const PROFILE_REL_TOL: f64 = 1.0e-12;
const PROFILE_MAX_ITERS: usize = 400;
pub fn dimension_spectrometer(
data: ArrayView2<'_, f32>,
cfg: &SpectrometerConfig,
) -> Result<SpectrometerReport, String> {
if data.nrows() == 0 || data.ncols() == 0 {
return Err("dimension_spectrometer requires a non-empty N×P matrix".to_string());
}
if !data.iter().all(|v| v.is_finite()) {
return Err("dimension_spectrometer input must be finite".to_string());
}
if cfg.k_min == 0 {
return Err("dimension_spectrometer requires k_min >= 1".to_string());
}
if cfg.n_doublings < 3 {
return Err(
"dimension_spectrometer requires n_doublings >= 3 (>= 4 rungs): the scaling law has \
three parameters (σ², slope, intercept), so a slope standard error needs at least \
one residual degree of freedom (nrung − 3 >= 1)"
.to_string(),
);
}
if cfg.n_doublings >= usize::BITS as usize {
return Err("dimension_spectrometer: rung width overflows usize".to_string());
}
let mut widths: Vec<usize> = Vec::with_capacity(cfg.n_doublings + 1);
let mut k = cfg.k_min;
widths.push(k);
for j in 1..=cfg.n_doublings {
k = k.checked_mul(2).ok_or_else(|| {
format!("dimension_spectrometer: rung width k_min·2^{j} overflows usize")
})?;
widths.push(k);
}
let mut rungs: Vec<(usize, f64)> = Vec::with_capacity(widths.len());
let mut previous = None;
for &k in &widths {
let mut rung_cfg = cfg.dict;
rung_cfg.n_atoms = k;
rung_cfg.active = 1;
let fit = match previous.as_ref() {
Some(prior) => crate::sparse_dict::extend_linear_reml_schedule(data, &rung_cfg, prior),
None => fit_sparse_dictionary(data, &rung_cfg),
}
.map_err(|e| format!("dimension_spectrometer: fit at K={k} failed: {e}"))?;
let loss = rung_loss(&fit, data);
rungs.push((k, loss));
previous = Some(fit);
}
analyze_ladder(&rungs, data.nrows())
}
fn threshold_points_per_atom(d_hat: f64) -> f64 {
let statistical = (d_hat * d_hat) / (2.0 * std::f64::consts::LN_2);
statistical.max(2.0)
}
fn select_stable_window(
rungs: &[(usize, f64)],
n_rows: usize,
) -> Result<(usize, ScalingLaw), String> {
let mut count = 4;
if rungs.len() < count {
return Err("stable scaling window requires at least four rungs".to_string());
}
let mut law = fit_scaling_law(&rungs[..count])?;
let admissible = |law: &ScalingLaw, last: usize| {
law.d_hat.is_finite()
&& law.d_hat > 0.0
&& (n_rows as f64) / rungs[last].0 as f64 >= threshold_points_per_atom(law.d_hat)
};
if !admissible(&law, count - 1) {
return Err(format!(
"stable scaling window has no sampling-admissible four-rung seed: \
d={}, N/K={} at K={}",
law.d_hat,
n_rows as f64 / rungs[count - 1].0 as f64,
rungs[count - 1].0,
));
}
while count < rungs.len() && admissible(&law, count) {
let next = fit_scaling_law(&rungs[..count + 1])?;
if !admissible(&next, count) {
break;
}
law = next;
count += 1;
}
Ok((count, law))
}
fn analyze_ladder(rungs: &[(usize, f64)], n_rows: usize) -> Result<SpectrometerReport, String> {
if rungs.len() < 4 {
return Err("analyze_ladder requires at least 4 rungs".to_string());
}
let full = fit_scaling_law(rungs)?;
let (stable_rung_count, stable) = select_stable_window(rungs, n_rows)?;
let d_hat_drop_last = fit_scaling_law(&rungs[..rungs.len() - 1])?.d_hat;
let points_per_atom: Vec<f64> = rungs
.iter()
.map(|&(k, _)| n_rows as f64 / k as f64)
.collect();
let threshold = threshold_points_per_atom(stable.d_hat);
let small_nk_regime =
stable_rung_count < rungs.len() || points_per_atom.iter().any(|&r| r < threshold);
let lo_k = rungs[0].0;
let hi_k = rungs[stable_rung_count - 1].0;
Ok(SpectrometerReport {
rungs: rungs.to_vec(),
n_rows,
points_per_atom,
noise_floor: stable.sigma2,
slope: stable.slope,
slope_se: stable.slope_se,
d_hat: stable.d_hat,
d_hat_se: stable.d_hat_se,
d_hat_full_ladder: full.d_hat,
d_hat_drop_last,
stable_window_lo_k: lo_k,
stable_window_hi_k: hi_k,
stable_rung_count,
min_points_per_atom: threshold,
floor_saturated: stable.floor_saturated,
small_nk_regime,
})
}
fn rung_loss(fit: &crate::sparse_dict::SparseDictFit, data: ArrayView2<'_, f32>) -> f64 {
let n = data.nrows();
let p = data.ncols();
let s = fit.indices.ncols();
let mut recon = vec![0.0f64; p];
let mut acc = 0.0f64;
for i in 0..n {
for c in 0..p {
recon[c] = 0.0;
}
for j in 0..s {
let cj = fit.codes[[i, j]] as f64;
if cj == 0.0 {
continue;
}
let atom = fit.indices[[i, j]] as usize;
let drow = fit.decoder.row(atom);
for c in 0..p {
recon[c] += cj * drow[c] as f64;
}
}
let xi = data.row(i);
let mut ri = 0.0f64;
for c in 0..p {
let r = xi[c] as f64 - recon[c];
ri += r * r;
}
acc += ri;
}
acc / n as f64
}
struct ScalingLaw {
sigma2: f64,
slope: f64,
slope_se: f64,
d_hat: f64,
d_hat_se: f64,
floor_saturated: bool,
}
struct LineFit {
slope: f64,
rss: f64,
}
fn ols_log_excess(losses: &[f64], t: &[f64], t_bar: f64, stt: f64, sigma2: f64) -> Option<LineFit> {
let nrung = losses.len();
let mut y = vec![0.0f64; nrung];
let mut y_bar = 0.0f64;
for i in 0..nrung {
let excess = losses[i] - sigma2;
if excess <= 0.0 {
return None;
}
let yi = excess.ln();
y[i] = yi;
y_bar += yi;
}
y_bar /= nrung as f64;
let mut sty = 0.0f64;
for i in 0..nrung {
sty += (t[i] - t_bar) * y[i];
}
let slope = sty / stt;
let intercept = y_bar - slope * t_bar;
let mut rss = 0.0f64;
for i in 0..nrung {
let resid = y[i] - intercept - slope * t[i];
rss += resid * resid;
}
Some(LineFit { slope, rss })
}
fn fit_scaling_law(rungs: &[(usize, f64)]) -> Result<ScalingLaw, String> {
let nrung = rungs.len();
if nrung < 3 {
return Err("fit_scaling_law requires at least 3 rungs".to_string());
}
let losses: Vec<f64> = rungs.iter().map(|&(_, l)| l).collect();
if !losses.iter().all(|v| v.is_finite() && *v >= 0.0) {
return Err("fit_scaling_law: per-rung losses must be finite and non-negative".to_string());
}
let t: Vec<f64> = rungs.iter().map(|&(k, _)| (k as f64).ln()).collect();
let t_bar = t.iter().sum::<f64>() / nrung as f64;
let stt: f64 = t.iter().map(|&ti| (ti - t_bar) * (ti - t_bar)).sum();
if stt <= 0.0 {
return Err(
"fit_scaling_law: log-K design is degenerate (all rungs equal width)".to_string(),
);
}
let l_min = losses.iter().cloned().fold(f64::INFINITY, f64::min);
let hi = l_min * (1.0 - 1.0e-9);
let lo = 0.0f64;
let objective = |sigma2: f64| -> f64 {
match ols_log_excess(&losses, &t, t_bar, stt, sigma2) {
Some(fit) => fit.rss,
None => f64::INFINITY,
}
};
let sigma2 = golden_section_min(objective, lo, hi);
let fit = ols_log_excess(&losses, &t, t_bar, stt, sigma2)
.ok_or_else(|| "fit_scaling_law: profiled σ² left an undefined log-excess".to_string())?;
let slope = fit.slope;
let dof = (nrung as f64) - 3.0;
let s2 = if dof > 0.0 {
fit.rss / dof
} else {
f64::INFINITY
};
let slope_se = (s2 / stt).sqrt();
let d_hat = -2.0 / slope;
let d_hat_se = (2.0 / (slope * slope)) * slope_se;
let ci_half = NORMAL_95_QUANTILE * slope_se;
let floor_saturated = !(slope < 0.0) || slope.abs() <= ci_half;
Ok(ScalingLaw {
sigma2,
slope,
slope_se,
d_hat,
d_hat_se,
floor_saturated,
})
}
fn golden_section_min<F: FnMut(f64) -> f64>(mut f: F, mut lo: f64, mut hi: f64) -> f64 {
let inv_phi = (5.0f64.sqrt() - 1.0) / 2.0;
if !(hi > lo) {
return lo;
}
let mut c = hi - inv_phi * (hi - lo);
let mut d = lo + inv_phi * (hi - lo);
let mut fc = f(c);
let mut fd = f(d);
for _ in 0..PROFILE_MAX_ITERS {
if (hi - lo).abs() <= PROFILE_REL_TOL * (1.0 + lo.abs() + hi.abs()) {
break;
}
if fc < fd {
hi = d;
d = c;
fd = fc;
c = hi - inv_phi * (hi - lo);
fc = f(c);
} else {
lo = c;
c = d;
fc = fd;
d = lo + inv_phi * (hi - lo);
fd = f(d);
}
}
0.5 * (lo + hi)
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::Array2;
fn split_next(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn split_unit(state: &mut u64) -> f64 {
(split_next(state) >> 11) as f64 / ((1u64 << 53) as f64)
}
fn split_normal(state: &mut u64) -> f64 {
let u1 = split_unit(state).max(1.0e-12);
let u2 = split_unit(state);
(-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
fn orthonormal_frame(p: usize, cols: usize, seed: u64) -> Array2<f32> {
use gam_linalg::faer_ndarray::FaerEigh;
let mut state = seed;
let mut a = Array2::<f64>::zeros((p, p));
for i in 0..p {
for j in 0..p {
a[[i, j]] = split_normal(&mut state);
}
}
let sym = &a + &a.t();
let (_evals, evecs) = sym.eigh(faer::Side::Lower).expect("orthonormal frame eig");
let mut frame = Array2::<f32>::zeros((p, cols));
for c in 0..cols {
for r in 0..p {
frame[[r, c]] = evecs[[r, c]] as f32;
}
}
frame
}
fn circle(n: usize, p: usize, noise: f32, seed: u64) -> Array2<f32> {
let frame = orthonormal_frame(p, 2, seed);
let mut state = seed ^ 0xD1B5_4A32_D192_ED03;
let mut x = Array2::<f32>::zeros((n, p));
for i in 0..n {
let theta = std::f64::consts::TAU * split_unit(&mut state);
let c0 = theta.cos() as f32;
let c1 = theta.sin() as f32;
for r in 0..p {
let signal = c0 * frame[[r, 0]] + c1 * frame[[r, 1]];
let eps = noise * split_normal(&mut state) as f32;
x[[i, r]] = signal + eps;
}
}
x
}
fn torus(n: usize, p: usize, noise: f32, seed: u64) -> Array2<f32> {
let frame = orthonormal_frame(p, 4, seed);
let mut state = seed ^ 0x9E37_79B9_7F4A_7C15;
let mut x = Array2::<f32>::zeros((n, p));
for i in 0..n {
let phi1 = std::f64::consts::TAU * split_unit(&mut state);
let phi2 = std::f64::consts::TAU * split_unit(&mut state);
let a0 = phi1.cos() as f32;
let a1 = phi1.sin() as f32;
let a2 = phi2.cos() as f32;
let a3 = phi2.sin() as f32;
for r in 0..p {
let signal = a0 * frame[[r, 0]]
+ a1 * frame[[r, 1]]
+ a2 * frame[[r, 2]]
+ a3 * frame[[r, 3]];
let eps = noise * split_normal(&mut state) as f32;
x[[i, r]] = signal + eps;
}
}
x
}
fn dict_template() -> SparseDictConfig {
SparseDictConfig {
n_atoms: 1,
active: 1,
minibatch: 1024,
max_epochs: 25,
score_tile: 256,
code_ridge: 1.0e-6,
decoder_ridge: 1.0e-6,
tolerance: 1.0e-6,
score_mode: gam_gpu::GpuPolicy::Off,
}
}
fn assert_losses_decreasing(rungs: &[(usize, f64)]) {
for w in rungs.windows(2) {
assert!(
w[1].1 <= w[0].1 + 1.0e-9,
"loss must not increase with K: K={} loss={} then K={} loss={}",
w[0].0,
w[0].1,
w[1].0,
w[1].1
);
}
}
#[test]
fn spectrometer_recovers_circle_dimension_one() {
let p = 64usize;
let x = circle(4000, p, 3.0e-4, 0x00C1_2345);
let cfg = SpectrometerConfig {
k_min: 4,
n_doublings: 5, dict: dict_template(),
};
let report = dimension_spectrometer(x.view(), &cfg).expect("circle spectrometer");
assert_eq!(report.rungs.len(), 6);
assert_losses_decreasing(&report.rungs);
assert!(
report.slope < 0.0,
"slope must be negative (loss decays in K), got {}",
report.slope
);
assert!(
!report.floor_saturated,
"circle ladder should resolve a slope (not floor-saturated); slope={} se={}",
report.slope, report.slope_se
);
assert!(
(report.d_hat - 1.0).abs() < 0.5,
"d̂ should recover 1 for the circle, got {} (slope {}, σ²={})",
report.d_hat,
report.slope,
report.noise_floor
);
assert!(
!report.small_nk_regime,
"well-sampled circle must not flag small-N/K (stable rungs {} of {})",
report.stable_rung_count,
report.rungs.len()
);
assert_eq!(report.stable_rung_count, report.rungs.len());
assert!((report.d_hat - report.d_hat_full_ladder).abs() < 1.0e-9);
}
#[test]
fn overflowing_ladders_are_refused_before_allocating_or_fitting() {
let data = ndarray::array![[1.0_f32, 0.0]];
for (k_min, n_doublings) in [(usize::MAX / 2 + 1, 3), (1, usize::MAX)] {
let config = SpectrometerConfig {
k_min,
n_doublings,
dict: dict_template(),
};
let error = dimension_spectrometer(data.view(), &config).unwrap_err();
assert!(error.contains("overflows usize"), "{error}");
}
}
#[test]
fn spectrometer_recovers_torus_dimension_two() {
let p = 64usize;
let x = torus(6000, p, 3.0e-4, 0x00D2_9876);
let cfg = SpectrometerConfig {
k_min: 4,
n_doublings: 6, dict: dict_template(),
};
let report = dimension_spectrometer(x.view(), &cfg).expect("torus spectrometer");
assert_eq!(report.rungs.len(), 7);
assert_losses_decreasing(&report.rungs);
assert!(
report.slope < 0.0,
"slope must be negative (loss decays in K), got {}",
report.slope
);
assert!(
!report.floor_saturated,
"torus ladder should resolve a slope (not floor-saturated); slope={} se={}",
report.slope, report.slope_se
);
assert!(
(report.d_hat - 2.0).abs() < 0.5,
"d̂ should recover 2 for the 2-torus, got {} (slope {}, σ²={})",
report.d_hat,
report.slope,
report.noise_floor
);
assert!(
!report.small_nk_regime,
"well-sampled torus must not flag small-N/K (stable rungs {} of {})",
report.stable_rung_count,
report.rungs.len()
);
assert_eq!(report.stable_rung_count, report.rungs.len());
}
#[test]
fn scaling_law_recovers_planted_power_law_with_floor() {
let sigma2_true = 0.02f64;
let c = 1.5f64;
let m_true = -1.0f64; let mut rungs: Vec<(usize, f64)> = Vec::new();
let mut k = 4usize;
for _ in 0..7 {
let loss = sigma2_true + c * (k as f64).powf(m_true);
rungs.push((k, loss));
k *= 2;
}
let law = fit_scaling_law(&rungs).expect("scaling law fit");
assert!(
(law.slope - m_true).abs() < 1.0e-3,
"recovered slope {} should match planted {m_true}",
law.slope
);
assert!(
(law.d_hat - 2.0).abs() < 1.0e-2,
"recovered d̂ {} should match planted 2",
law.d_hat
);
assert!(
(law.sigma2 - sigma2_true).abs() < 1.0e-3,
"profiled σ² {} should recover planted floor {sigma2_true}",
law.sigma2
);
assert!(
!law.floor_saturated,
"clean power law must not read as saturated"
);
}
#[test]
fn flat_losses_flag_floor_saturation() {
let rungs: Vec<(usize, f64)> = [4, 8, 16, 32, 64].iter().map(|&k| (k, 0.05f64)).collect();
let law = fit_scaling_law(&rungs).expect("flat scaling law fit");
assert!(
law.floor_saturated,
"flat losses must flag floor saturation (slope {} se {})",
law.slope, law.slope_se
);
}
#[test]
fn stable_window_resists_high_k_overfit_drift() {
let d_true = 8.0f64;
let n_rows = 2000usize;
let sigma2 = 1.0e-3f64;
let c = 1.0f64;
let mut rungs: Vec<(usize, f64)> = Vec::new();
let mut k = 4usize;
for _ in 0..9 {
let population = sigma2 + c * (k as f64).powf(-2.0 / d_true);
let n_cell = n_rows as f64 / k as f64;
let overfit = (1.0 - d_true / n_cell).max(0.05);
rungs.push((k, population * overfit));
k *= 2;
}
let report = analyze_ladder(&rungs, n_rows).expect("ladder analysis");
assert!(
report.small_nk_regime,
"token-starved tail must flag small-N/K regime"
);
assert!(
report.stable_rung_count < report.rungs.len(),
"stable window must drop the token-starved rungs (kept {} of {})",
report.stable_rung_count,
report.rungs.len()
);
assert!(
report.stable_window_hi_k < rungs[rungs.len() - 1].0,
"stable window must exclude the largest-K rung ({} vs top {})",
report.stable_window_hi_k,
rungs[rungs.len() - 1].0
);
assert!(
report.d_hat_full_ladder < d_true - 1.0,
"full-ladder d̂ {} should be biased below the true d={d_true}",
report.d_hat_full_ladder
);
assert!(
report.d_hat > report.d_hat_full_ladder + 1.0,
"stable-window d̂ {} must correct the full-ladder d̂ {} upward",
report.d_hat,
report.d_hat_full_ladder
);
assert!(
(report.d_hat - d_true).abs() < (report.d_hat_full_ladder - d_true).abs(),
"stable-window d̂ {} must be closer to d={d_true} than full-ladder d̂ {}",
report.d_hat,
report.d_hat_full_ladder
);
assert!(
report.d_hat_drop_last.is_finite() && report.d_hat_drop_last > 0.0,
"drop-last d̂ must be a finite positive estimate, got {}",
report.d_hat_drop_last
);
assert_eq!(report.points_per_atom.len(), report.rungs.len());
assert!((report.points_per_atom[0] - n_rows as f64 / rungs[0].0 as f64).abs() < 1.0e-9);
let mut worse_tail = rungs.clone();
for (index, (_, loss)) in worse_tail
.iter_mut()
.skip(report.stable_rung_count)
.enumerate()
{
*loss *= 0.01_f64.powi(index as i32 + 1);
}
let (count, law) = select_stable_window(&worse_tail, n_rows).expect("same reliable prefix");
assert_eq!(count, report.stable_rung_count);
assert_eq!(law.d_hat, report.d_hat);
assert_eq!(law.sigma2, report.noise_floor);
}
#[test]
fn stable_window_refuses_undersampled_seed() {
let rungs: Vec<_> = [4_usize, 8, 16, 32, 64]
.into_iter()
.map(|k| (k, 0.02 + (k as f64).powf(-0.25)))
.collect();
let error = select_stable_window(&rungs, 100)
.err()
.expect("four reliable rungs are unavailable");
assert!(
error.contains("no sampling-admissible four-rung seed"),
"{error}"
);
}
#[test]
fn threshold_scales_like_dimension_squared() {
assert!(
(threshold_points_per_atom(1.0) - 2.0).abs() < 1.0e-12,
"d=1 threshold floors at 2, got {}",
threshold_points_per_atom(1.0)
);
let t_low = threshold_points_per_atom(4.0);
let t_high = threshold_points_per_atom(16.0);
assert!(
(t_high / t_low - 16.0).abs() < 1.0e-6,
"threshold must scale like d² (ratio {} for d 4→16)",
t_high / t_low
);
assert!(
t_high > 180.0,
"a d=16 manifold must demand many points per atom, got {t_high}"
);
}
}