use crate::graph::Graph;
#[derive(Clone, Debug, PartialEq)]
pub enum Finding {
BetaMismatch { requested: f64, effective: f64, ci: (f64, f64) },
Undermixed { tau_int: f64, ess: f64, draws: usize },
AboveNoiseFloor { tv: f64, floor: f64 },
NotConverged { early: f64, late: f64, sigma: f64 },
TooFewSamples { draws: usize },
}
impl core::fmt::Display for Finding {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Finding::BetaMismatch { requested, effective, ci } => write!(
f,
"sampled at beta {effective:.4} (95% CI {:.4}..{:.4}), not the requested {requested:.4}",
ci.0, ci.1
),
Finding::Undermixed { tau_int, ess, draws } => write!(
f,
"draws are correlated: tau_int {tau_int:.1}, so {draws} draws are worth about \
{ess:.0} independent samples; thin the chain or run longer"
),
Finding::AboveNoiseFloor { tv, floor } => write!(
f,
"total variation {tv:.4} exceeds the {floor:.4} sampling-noise floor, so the \
difference is real rather than finite-sample scatter"
),
Finding::NotConverged { early, late, sigma } => write!(
f,
"the chain was still moving: early draws average {early:.4} and late ones \
{late:.4}, a gap of {sigma:.1} standard errors; burn in for longer"
),
Finding::TooFewSamples { draws } => {
write!(
f,
"{draws} draws is too few to certify anything: the sampling-noise floor for a \
state space this large reaches or exceeds 1, which is the most a total \
variation can be, so a distributional comparison here cannot distinguish a \
good sampler from pure noise. Draw more, or certify a smaller model"
)
}
}
}
}
#[derive(Clone, Debug)]
pub struct Certificate {
pub draws: usize,
pub beta_requested: f64,
pub beta_eff: f64,
pub beta_ci: (f64, f64),
pub tau_int: f64,
pub ess: f64,
pub tv_exact: Option<f64>,
pub noise_floor: Option<f64>,
pub findings: Vec<Finding>,
}
impl Certificate {
#[must_use = "a certificate with findings is a certificate that failed; ignoring this is reporting a sound run that was not one"]
pub fn passed(&self) -> bool {
self.findings.is_empty()
}
}
impl core::fmt::Display for Certificate {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
writeln!(
f,
"draws {} beta {:.4} (asked {:.4}, CI {:.4}..{:.4}) tau_int {:.1} ess {:.0}",
self.draws, self.beta_eff, self.beta_requested, self.beta_ci.0, self.beta_ci.1,
self.tau_int, self.ess
)?;
if let (Some(tv), Some(fl)) = (self.tv_exact, self.noise_floor) {
writeln!(f, "tv {tv:.4} against a {fl:.4} noise floor")?;
}
if self.findings.is_empty() {
write!(f, "PASSED")
} else {
for x in &self.findings {
writeln!(f, "FINDING: {x}")?;
}
Ok(())
}
}
}
fn fit_beta(g: &Graph, samples: &[Vec<i8>]) -> (f64, f64) {
let mut obs: Vec<(f64, f64)> = Vec::with_capacity(samples.len() * g.n);
for s in samples {
for i in 0..g.n {
let f = g.field(i, s);
if f != 0.0 {
obs.push((2.0 * f, if s[i] > 0 { 1.0 } else { 0.0 }));
}
}
}
if obs.is_empty() {
return (f64::NAN, 0.0); }
let d1 = |b: f64| -> f64 {
obs.iter().map(|&(x, y)| x * (y - 1.0 / (1.0 + (-b * x).exp()))).sum()
};
const LIM: f64 = 60.0;
let (mut lo, mut hi) = (-LIM, LIM);
let (flo, fhi) = (d1(lo), d1(hi));
if flo <= 0.0 || fhi >= 0.0 {
let beta = if fhi >= 0.0 { LIM } else { -LIM };
return (beta, 0.0);
}
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if d1(mid) > 0.0 {
lo = mid;
} else {
hi = mid;
}
if hi - lo < 1e-12 {
break;
}
}
let beta = 0.5 * (lo + hi);
let info: f64 = obs
.iter()
.map(|&(x, _)| {
let p = 1.0 / (1.0 + (-beta * x).exp());
x * x * p * (1.0 - p)
})
.sum();
(beta, info)
}
pub fn tau_int(trace: &[f64]) -> f64 {
let n = trace.len();
if n < 16 {
return f64::NAN;
}
let mean = trace.iter().sum::<f64>() / n as f64;
let var = trace.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
if var <= 0.0 {
return f64::INFINITY; }
let max_lag = (n / 4).max(1);
let mut tau = 0.5;
for k in 1..=max_lag {
let mut c = 0.0;
for t in 0..(n - k) {
c += (trace[t] - mean) * (trace[t + k] - mean);
}
c /= (n - k) as f64 * var;
tau += c;
if (k as f64) >= 5.0 * tau.max(0.5) {
break;
}
}
tau.max(0.5)
}
pub fn certify(g: &Graph, beta_requested: f64, samples: &[Vec<i8>], trace: &[f64]) -> Certificate {
let draws = samples.len();
if draws < 16 {
return Certificate {
draws,
beta_requested,
beta_eff: f64::NAN,
beta_ci: (f64::NAN, f64::NAN),
tau_int: f64::NAN,
ess: f64::NAN,
tv_exact: None,
noise_floor: None,
findings: vec![Finding::TooFewSamples { draws }],
};
}
let (beta_eff, info) = fit_beta(g, samples);
let mag: Vec<f64> = samples
.iter()
.map(|s| s.iter().map(|&x| x as f64).sum::<f64>() / g.n as f64)
.collect();
let t = {
let a = tau_int(trace);
let b = tau_int(&mag);
match (a.is_nan(), b.is_nan()) {
(false, false) => a.max(b),
(true, false) => b,
(false, true) => a,
(true, true) => f64::NAN,
}
};
let ess = if t.is_finite() && t > 0.0 { draws as f64 / (2.0 * t) } else { 1.0 };
let se = if info > 0.0 { (1.0 / info).sqrt() } else { f64::INFINITY };
let inflate = if t.is_finite() { (2.0 * t).sqrt().max(1.0) } else { 1.0 };
let half = 1.96 * se * inflate;
let beta_ci = (beta_eff - half, beta_eff + half);
let mut findings = Vec::new();
if beta_eff.is_finite() && !(beta_ci.0 <= beta_requested && beta_requested <= beta_ci.1) {
findings.push(Finding::BetaMismatch {
requested: beta_requested,
effective: beta_eff,
ci: beta_ci,
});
}
if !t.is_finite() || ess < 50.0 || t > draws as f64 / 50.0 {
findings.push(Finding::Undermixed { tau_int: t, ess, draws });
}
if draws >= 60 {
let cut = draws / 3;
let early = &mag[..cut];
let late = &mag[draws - cut..];
let m = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
let (me, ml) = (m(early), m(late));
let var = |v: &[f64], mu: f64| {
v.iter().map(|x| (x - mu).powi(2)).sum::<f64>() / (v.len() as f64 - 1.0).max(1.0)
};
let infl = if t.is_finite() { (2.0 * t).max(1.0) } else { 1.0 };
let se = ((var(early, me) + var(late, ml)) * infl / cut as f64).sqrt();
if se > 0.0 {
let z = (me - ml).abs() / se;
if z > 4.0 {
findings.push(Finding::NotConverged { early: me, late: ml, sigma: z });
}
}
}
let (mut tv_exact, mut noise_floor) = (None, None);
if g.n <= 20 {
let exact = crate::ising::exact_boltzmann(g, beta_requested);
let mut hist = vec![0.0f64; 1 << g.n];
for s in samples {
let mut k = 0usize;
for (b, &v) in s.iter().enumerate() {
if v > 0 {
k |= 1 << b;
}
}
hist[k] += 1.0;
}
for h in hist.iter_mut() {
*h /= draws as f64;
}
let tv = crate::ising::tv(&hist, &exact);
let floor = 0.5 * ((1usize << g.n) as f64 / ess.max(1.0)).sqrt();
if floor >= 1.0 {
findings.push(Finding::TooFewSamples { draws });
} else if tv > floor {
findings.push(Finding::AboveNoiseFloor { tv, floor });
}
tv_exact = Some(tv);
noise_floor = Some(floor);
}
Certificate {
draws,
beta_requested,
beta_eff,
beta_ci,
tau_int: t,
ess,
tv_exact,
noise_floor,
findings,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gibbs::Sampler;
use crate::rng::Pcg;
fn run(g: &Graph, beta_actual: f64, thin: usize, burn: usize, draws: usize, seed: u64)
-> (Vec<Vec<i8>>, Vec<f64>)
{
let mut smp = Sampler::new(g, beta_actual, seed);
smp.sweeps(burn, None);
let mut samples = Vec::with_capacity(draws);
let mut trace = Vec::with_capacity(draws);
for _ in 0..draws {
smp.sweeps(thin.max(1), None);
samples.push(smp.s.clone());
trace.push(g.energy(&smp.s));
}
(samples, trace)
}
#[test]
fn a_correct_run_passes() {
let g = crate::ising::ring(10, 1.0, 0.2);
let (s, t) = run(&g, 0.6, 10, 500, 6000, 1);
let c = certify(&g, 0.6, &s, &t);
assert!(c.passed(), "a correct sampler should certify clean:\n{c}");
assert!((c.beta_eff - 0.6).abs() < 0.05, "beta_eff {} off", c.beta_eff);
}
#[test]
fn a_sampler_at_the_wrong_temperature_is_caught() {
let g = crate::ising::ring(10, 1.0, 0.2);
let (s, t) = run(&g, 1.4, 10, 500, 6000, 2);
let c = certify(&g, 0.6, &s, &t); assert!(!c.passed(), "a wrong temperature must be caught");
assert!(
c.findings.iter().any(|f| matches!(f, Finding::BetaMismatch { .. })),
"expected a BetaMismatch, got {:?}",
c.findings
);
assert!((c.beta_eff - 1.4).abs() < 0.1, "should recover the true beta, got {}", c.beta_eff);
}
#[test]
fn correlated_draws_are_caught() {
let g = crate::ising::lattice2d(12, 1.0);
for seed in 1..=3 {
let (s, t) = run(&g, 0.44, 1, 500, 3000, seed);
let c = certify(&g, 0.44, &s, &t);
assert!(
c.findings.iter().any(|f| matches!(f, Finding::Undermixed { .. })),
"seed {seed}: critical slowing down must be flagged: {c}"
);
assert!(c.ess < c.draws as f64 / 10.0, "seed {seed}: ess too high: {c}");
}
}
#[test]
fn thinning_repairs_what_correlation_broke() {
let g = crate::ising::lattice2d(24, 1.0);
let tight = { let (s, t) = run(&g, 0.7, 1, 0, 600, 3); certify(&g, 0.7, &s, &t) };
let fixed = { let (s, t) = run(&g, 0.7, 50, 500, 600, 3); certify(&g, 0.7, &s, &t) };
assert!(!tight.passed(), "the unfixed run should be flagged");
assert!(fixed.passed(), "burning in and thinning should clear it: {fixed}");
assert!(
fixed.ess > tight.ess * 10.0,
"ess should improve by an order of magnitude: {:.0} -> {:.0}",
tight.ess, fixed.ess
);
}
#[test]
fn an_unburned_chain_is_caught() {
let g = crate::ising::lattice2d(24, 1.0);
let (s, t) = run(&g, 0.7, 1, 0, 600, 4);
let c = certify(&g, 0.7, &s, &t);
assert!(!c.passed(), "an unburned coarsening chain must not certify clean:\n{c}");
let (s2, t2) = run(&g, 0.7, 1, 500, 600, 4);
assert!(certify(&g, 0.7, &s2, &t2).passed(), "burning in should clear it");
}
#[test]
fn the_convergence_check_sees_a_drifting_trace() {
let mut rng = Pcg::new(21, 0);
let n = 3000;
let drifting: Vec<f64> = (0..n)
.map(|i| i as f64 / n as f64 + 0.05 * (rng.f64() - 0.5))
.collect();
let steady: Vec<f64> = (0..n).map(|_| 0.5 + 0.05 * (rng.f64() - 0.5)).collect();
let m = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
let cut = n / 3;
assert!(
(m(&drifting[..cut]) - m(&drifting[n - cut..])).abs() > 0.5,
"the drifting trace must actually drift"
);
assert!(
(m(&steady[..cut]) - m(&steady[n - cut..])).abs() < 0.02,
"the steady trace must not"
);
}
#[test]
fn the_distributional_gate_fires_on_noise_rather_than_switching_itself_off() {
let mut rng = 0x2545F4914F6CDD1Du64;
let mut next = || {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
rng
};
for (side, draws) in [(3usize, 4000usize), (4, 4000)] {
let g = crate::ising::lattice2d(side, 1.0);
let samples: Vec<Vec<i8>> = (0..draws)
.map(|_| {
let r = next();
(0..g.n).map(|b| if r >> (b % 64) & 1 == 1 { 1 } else { -1 }).collect()
})
.collect();
let trace: Vec<f64> = samples.iter().map(|s| g.energy(s)).collect();
let c = certify(&g, 0.9, &samples, &trace);
assert!(!c.passed(), "n={} pure noise must not pass", g.n);
let spoke = c.findings.iter().any(|f| {
matches!(f, Finding::AboveNoiseFloor { .. } | Finding::TooFewSamples { .. })
});
assert!(spoke, "n={} said nothing about the distribution: {:?}", g.n, c.findings);
if let Some(floor) = c.noise_floor {
assert!(
floor < 1.0 || c.findings.iter().any(|f| matches!(f, Finding::TooFewSamples { .. })),
"n={}: floor {floor} is vacuous and nothing said so",
g.n
);
}
}
}
#[test]
fn pure_noise_is_caught() {
let g = crate::ising::ring(10, 1.0, 0.3);
let mut rng = Pcg::new(9, 0);
let samples: Vec<Vec<i8>> = (0..4000)
.map(|_| (0..g.n).map(|_| if rng.f64() < 0.5 { 1 } else { -1 }).collect())
.collect();
let trace: Vec<f64> = samples.iter().map(|s| g.energy(s)).collect();
let c = certify(&g, 1.0, &samples, &trace);
assert!(!c.passed(), "uniform noise must never certify as Boltzmann:\n{c}");
assert!(c.beta_eff.abs() < 0.15, "noise should fit beta near 0, got {}", c.beta_eff);
}
#[test]
fn the_noise_floor_is_reported_beside_the_distance() {
let g = crate::ising::ring(8, 1.0, 0.0);
let (s, t) = run(&g, 0.5, 8, 400, 5000, 6);
let c = certify(&g, 0.5, &s, &t);
assert!(c.tv_exact.is_some() && c.noise_floor.is_some());
assert!(c.noise_floor.unwrap() > 0.0);
}
#[test]
fn too_few_samples_says_so_rather_than_guessing() {
let g = crate::ising::ring(8, 1.0, 0.0);
let (s, t) = run(&g, 1.0, 1, 10, 8, 7);
let c = certify(&g, 1.0, &s, &t);
assert_eq!(c.findings, vec![Finding::TooFewSamples { draws: 8 }]);
}
#[test]
fn tau_int_recovers_a_known_correlation() {
let mut rng = Pcg::new(3, 0);
for &p in &[0.0f64, 0.5, 0.8] {
let mut x = 0.0;
let trace: Vec<f64> = (0..200_000)
.map(|_| {
let g = (-2.0 * rng.f64().max(1e-12).ln()).sqrt()
* (core::f64::consts::TAU * rng.f64()).cos();
x = p * x + (1.0 - p * p).sqrt() * g;
x
})
.collect();
let want = (1.0 + p) / (2.0 * (1.0 - p));
let got = tau_int(&trace);
assert!((got - want).abs() / want < 0.25, "p={p}: got {got}, want {want}");
}
}
}