use crate::certify::{tau_int, Certificate};
use crate::graph::Graph;
use crate::ledger::Ledger;
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Provenance {
Chain { beta: f64, burn_in: usize, thin: usize },
Population { beta: f64, rho: f64 },
Enumerated { beta: f64 },
Search { method: &'static str },
}
impl Provenance {
pub fn beta(&self) -> Option<f64> {
match *self {
Provenance::Chain { beta, .. }
| Provenance::Population { beta, .. }
| Provenance::Enumerated { beta } => Some(beta),
Provenance::Search { .. } => None,
}
}
pub fn is_distributional(&self) -> bool {
!matches!(self, Provenance::Search { .. })
}
pub fn label(&self) -> &'static str {
match self {
Provenance::Chain { .. } => "chain",
Provenance::Population { .. } => "population",
Provenance::Enumerated { .. } => "enumerated",
Provenance::Search { .. } => "search",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Refused {
NotDistributional { method: &'static str },
NotAChain { provenance: &'static str },
Empty,
TooLargeToEnumerate { spins: usize, limit: usize },
}
impl core::fmt::Display for Refused {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Refused::NotDistributional { method } => write!(
f,
"these states were visited by {method}, which is a search and not a sampler: how \
often it saw a state is a fact about where it walked, not about the model's \
probability of that state. `best`, `distinct` and `ground_states` still answer"
),
Refused::NotAChain { provenance } => write!(
f,
"this is a {provenance} set, and the question needs successive draws from one \
chain in order -- autocorrelation and the early-versus-late drift check are both \
statements about that order, and there is none here"
),
Refused::Empty => write!(f, "the set is empty, so there is nothing to average"),
Refused::TooLargeToEnumerate { spins, limit } => write!(
f,
"exhaustive enumeration materialises 2^{spins} states, and the limit is 2^{limit}; \
above that the states alone exceed what this will allocate. Sample instead, or \
use `exact::marginals`, which gets single-site marginals from elimination without \
enumerating anything"
),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Estimate {
pub value: f64,
pub stderr: f64,
pub ess: f64,
pub tau_int: f64,
}
impl Estimate {
pub fn ci95(&self) -> (f64, f64) {
(self.value - 1.96 * self.stderr, self.value + 1.96 * self.stderr)
}
pub fn covers(&self, truth: f64) -> bool {
let (lo, hi) = self.ci95();
lo <= truth && truth <= hi
}
}
impl core::fmt::Display for Estimate {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{:.5} +- {:.5} (ess {:.0}", self.value, self.stderr, self.ess)?;
if self.tau_int.is_finite() {
write!(f, ", tau {:.1}", self.tau_int)?;
}
write!(f, ")")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Plan {
pub burn_in: usize,
pub draws: usize,
pub thin: usize,
}
impl Plan {
pub fn new(burn_in: usize, draws: usize, thin: usize) -> Plan {
Plan { burn_in, draws, thin: thin.max(1) }
}
pub fn sweeps(&self) -> usize {
self.burn_in + self.draws * self.thin.max(1)
}
}
#[derive(Clone, Debug)]
pub struct SampleSet {
states: Vec<Vec<i8>>,
energies: Vec<f64>,
weights: Option<Vec<f64>>,
prov: Provenance,
n: usize,
chain_tau: f64,
}
impl SampleSet {
pub fn from_chain(
states: Vec<Vec<i8>>,
energies: Vec<f64>,
beta: f64,
burn_in: usize,
thin: usize,
) -> SampleSet {
SampleSet::build(states, energies, None, Provenance::Chain { beta, burn_in, thin })
}
pub fn from_population(
states: Vec<Vec<i8>>,
energies: Vec<f64>,
beta: f64,
rho: f64,
) -> SampleSet {
SampleSet::build(states, energies, None, Provenance::Population { beta, rho })
}
pub fn from_search(
states: Vec<Vec<i8>>,
energies: Vec<f64>,
method: &'static str,
) -> SampleSet {
SampleSet::build(states, energies, None, Provenance::Search { method })
}
fn build(
states: Vec<Vec<i8>>,
energies: Vec<f64>,
weights: Option<Vec<f64>>,
prov: Provenance,
) -> SampleSet {
assert_eq!(
states.len(),
energies.len(),
"one energy per state: {} states and {} energies is a set that cannot be indexed",
states.len(),
energies.len()
);
let n = states.first().map_or(0, |s| s.len());
assert!(
states.iter().all(|s| s.len() == n),
"every state must have the same width; mixing widths in one set makes `mean_spin(i)` \
mean different things for different draws"
);
let chain_tau = match prov {
Provenance::Chain { .. } => {
let mag: Vec<f64> = states
.iter()
.map(|s| s.iter().map(|&v| v as f64).sum::<f64>() / n.max(1) as f64)
.collect();
let a = tau_int(&energies);
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,
}
}
_ => f64::NAN,
};
SampleSet { states, energies, weights, prov, n, chain_tau }
}
pub fn chain_tau(&self) -> f64 {
self.chain_tau
}
pub fn len(&self) -> usize {
self.states.len()
}
pub fn is_empty(&self) -> bool {
self.states.is_empty()
}
pub fn n_spins(&self) -> usize {
self.n
}
pub fn provenance(&self) -> Provenance {
self.prov
}
pub fn states(&self) -> &[Vec<i8>] {
&self.states
}
pub fn energies(&self) -> &[f64] {
&self.energies
}
pub fn best(&self) -> Option<(&[i8], f64)> {
let mut k = 0usize;
if self.energies.is_empty() {
return None;
}
for i in 1..self.energies.len() {
if self.energies[i] < self.energies[k] {
k = i;
}
}
Some((&self.states[k], self.energies[k]))
}
pub fn distinct(&self) -> Vec<(Vec<i8>, f64, usize)> {
let mut seen: BTreeMap<&[i8], (f64, usize)> = BTreeMap::new();
for (s, &e) in self.states.iter().zip(self.energies.iter()) {
let slot = seen.entry(s.as_slice()).or_insert((e, 0));
slot.1 += 1;
}
let mut out: Vec<(Vec<i8>, f64, usize)> =
seen.into_iter().map(|(s, (e, c))| (s.to_vec(), e, c)).collect();
out.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal).then_with(|| a.0.cmp(&b.0)));
out
}
pub fn ground_states(&self, tol: f64) -> Vec<Vec<i8>> {
let Some((_, e0)) = self.best() else { return Vec::new() };
self.distinct()
.into_iter()
.filter(|(_, e, _)| *e <= e0 + tol)
.map(|(s, _, _)| s)
.collect()
}
pub fn mean_energy(&self) -> Result<Estimate, Refused> {
let e = self.energies.clone();
self.estimate_from(&e)
}
pub fn mean_spin(&self, i: usize) -> Result<Estimate, Refused> {
self.expectation(|s| s[i] as f64)
}
pub fn marginals(&self) -> Result<Vec<Estimate>, Refused> {
(0..self.n).map(|i| self.mean_spin(i)).collect()
}
pub fn correlation(&self, i: usize, j: usize) -> Result<Estimate, Refused> {
self.expectation(|s| (s[i] * s[j]) as f64)
}
pub fn magnetization(&self) -> Result<Estimate, Refused> {
let n = self.n as f64;
self.expectation(move |s| s.iter().map(|&v| v as f64).sum::<f64>() / n)
}
pub fn expectation<F: Fn(&[i8]) -> f64>(&self, f: F) -> Result<Estimate, Refused> {
let vals: Vec<f64> = self.states.iter().map(|s| f(s)).collect();
self.estimate_from(&vals)
}
fn estimate_from(&self, vals: &[f64]) -> Result<Estimate, Refused> {
if let Provenance::Search { method } = self.prov {
return Err(Refused::NotDistributional { method });
}
if vals.is_empty() {
return Err(Refused::Empty);
}
let n = vals.len();
if let Some(w) = &self.weights {
let value = vals.iter().zip(w.iter()).map(|(v, wk)| v * wk).sum::<f64>();
return Ok(Estimate { value, stderr: 0.0, ess: f64::INFINITY, tau_int: f64::NAN });
}
let value = vals.iter().sum::<f64>() / n as f64;
let var = if n > 1 {
vals.iter().map(|v| (v - value).powi(2)).sum::<f64>() / (n - 1) as f64
} else {
0.0
};
let (tau, ess) = match self.prov {
Provenance::Chain { .. } => {
let own = tau_int(vals);
let t = match (own.is_nan(), self.chain_tau.is_nan()) {
(false, false) => own.max(self.chain_tau),
(true, false) => self.chain_tau,
_ => own,
};
let e = if t.is_finite() && t > 0.0 { n as f64 / (2.0 * t) } else { 1.0 };
(t, e)
}
Provenance::Population { rho, .. } => {
let r = if rho.is_finite() && rho >= 1.0 { rho } else { 1.0 };
(f64::NAN, (n as f64 / r).max(1.0))
}
Provenance::Enumerated { .. } => (f64::NAN, f64::INFINITY),
Provenance::Search { .. } => unreachable!("refused above"),
};
let stderr = if ess.is_finite() && ess > 0.0 { (var / ess).sqrt() } else { 0.0 };
Ok(Estimate { value, stderr, ess, tau_int: tau })
}
pub fn certificate(&self, g: &Graph) -> Result<Certificate, Refused> {
match self.prov {
Provenance::Chain { beta, .. } => {
Ok(crate::certify::certify(g, beta, &self.states, &self.energies))
}
Provenance::Search { method } => Err(Refused::NotDistributional { method }),
other => Err(Refused::NotAChain { provenance: other.label() }),
}
}
}
pub const ENUMERATION_LIMIT: usize = 20;
pub fn enumerate(g: &Graph, beta: f64) -> Result<SampleSet, Refused> {
if g.n > ENUMERATION_LIMIT {
return Err(Refused::TooLargeToEnumerate { spins: g.n, limit: ENUMERATION_LIMIT });
}
let m = 1usize << g.n;
let mut states = Vec::with_capacity(m);
let mut energies = Vec::with_capacity(m);
let mut logw = Vec::with_capacity(m);
let mut mx = f64::NEG_INFINITY;
for mask in 0..m {
let s: Vec<i8> = (0..g.n).map(|b| if mask >> b & 1 == 1 { 1 } else { -1 }).collect();
let e = g.energy(&s);
let l = -beta * e;
if l > mx {
mx = l;
}
states.push(s);
energies.push(e);
logw.push(l);
}
let mut z = 0.0;
for v in logw.iter_mut() {
*v = (*v - mx).exp();
z += *v;
}
for v in logw.iter_mut() {
*v /= z;
}
Ok(SampleSet::build(states, energies, Some(logw), Provenance::Enumerated { beta }))
}
impl<'g> crate::gibbs::Sampler<'g> {
pub fn collect(&mut self, plan: &Plan, mut ledger: Option<&mut Ledger>) -> SampleSet {
let thin = plan.thin.max(1);
self.sweeps(plan.burn_in, ledger.as_deref_mut());
let mut states = Vec::with_capacity(plan.draws);
let mut energies = Vec::with_capacity(plan.draws);
for _ in 0..plan.draws {
self.sweeps(thin, ledger.as_deref_mut());
let s = self.read_all(ledger.as_deref_mut());
energies.push(self.g.energy(&s));
states.push(s);
}
SampleSet::from_chain(states, energies, self.beta, plan.burn_in, thin)
}
pub fn collect_par(
&mut self,
plan: &Plan,
threads: usize,
mut ledger: Option<&mut Ledger>,
) -> SampleSet {
let thin = plan.thin.max(1);
self.sweeps_par(plan.burn_in, threads, ledger.as_deref_mut());
let mut states = Vec::with_capacity(plan.draws);
let mut energies = Vec::with_capacity(plan.draws);
for _ in 0..plan.draws {
self.sweeps_par(thin, threads, ledger.as_deref_mut());
let s = self.read_all(ledger.as_deref_mut());
energies.push(self.g.energy(&s));
states.push(s);
}
SampleSet::from_chain(states, energies, self.beta, plan.burn_in, thin)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gibbs::Sampler;
use crate::graph::GraphBuilder;
use crate::rng::Pcg;
fn glass(n: usize, seed: u64, reach: usize) -> Graph {
let mut r = Pcg::new(seed, 7);
let mut gb = GraphBuilder::new(n);
for i in 0..n {
for k in 1..=reach {
let j = (i + k) % n;
if i < j {
gb.couple(i, j, if r.f64() < 0.5 { 1.0 } else { -1.0 });
}
}
}
gb.build()
}
fn exact_means(g: &Graph, beta: f64) -> Vec<f64> {
let p = crate::ising::exact_boltzmann(g, beta);
let mut out = vec![0.0; g.n];
for (mask, &pk) in p.iter().enumerate() {
for (i, o) in out.iter_mut().enumerate() {
*o += pk * if mask >> i & 1 == 1 { 1.0 } else { -1.0 };
}
}
out
}
#[test]
fn an_enumerated_set_is_the_oracle_and_reports_no_sampling_error() {
let g = glass(10, 11, 2);
let beta = 0.9;
let truth = exact_means(&g, beta);
let set = enumerate(&g, beta).expect("10 spins is under the limit");
assert_eq!(set.len(), 1 << 10);
for i in 0..g.n {
let e = set.mean_spin(i).expect("enumeration is distributional");
assert!(
(e.value - truth[i]).abs() < 1e-12,
"site {i}: enumerated {} vs oracle {}",
e.value,
truth[i]
);
assert_eq!(e.stderr, 0.0, "nothing was sampled, so there is no sampling error");
assert!(e.ess.is_infinite());
}
}
#[test]
fn the_corrected_interval_covers_the_exact_marginal_and_the_naive_one_does_not() {
let cases: [(Graph, f64); 2] = [(crate::ising::ring(12, 1.0, 0.0), 0.8), (glass(14, 3, 2), 0.5)];
let (mut hit, mut naive_hit, mut total) = (0usize, 0usize, 0usize);
for (g, beta) in &cases {
let truth = exact_means(g, *beta);
for seed in 0..12u64 {
let mut smp = Sampler::new(g, *beta, seed * 7919 + 1);
let set = smp.collect(&Plan::new(2_000, 4_000, 1), None);
for i in 0..g.n {
let e = set.mean_spin(i).expect("a chain is distributional");
let naive_se = e.stderr * (e.ess / set.len() as f64).sqrt();
if e.covers(truth[i]) {
hit += 1;
}
if (e.value - truth[i]).abs() <= 1.96 * naive_se {
naive_hit += 1;
}
total += 1;
}
}
}
let cov = hit as f64 / total as f64;
let naive = naive_hit as f64 / total as f64;
assert!(cov >= 0.90, "corrected interval covered {cov:.3} of {total}, claiming 0.95");
assert!(
naive <= 0.75,
"the naive sqrt(var/N) interval covered {naive:.3}, which would mean the \
autocorrelation correction this module exists for is not doing anything"
);
}
#[test]
fn a_sites_own_autocorrelation_is_a_floor_and_never_the_whole_correction() {
let g = glass(14, 3, 2);
let beta = 1.0;
let mut smp = Sampler::new(&g, beta, 99);
let set = smp.collect(&Plan::new(2_000, 4_000, 1), None);
assert!(set.chain_tau() > 1.0, "this beta is meant to be in the slow regime");
let mut lifted = 0;
for i in 0..g.n {
let e = set.mean_spin(i).unwrap();
assert!(
e.tau_int >= set.chain_tau() - 1e-9,
"site {i} reported tau {} below the chain's {}",
e.tau_int,
set.chain_tau()
);
let own = tau_int(&set.states().iter().map(|s| s[i] as f64).collect::<Vec<_>>());
if own < set.chain_tau() - 1e-9 {
lifted += 1;
}
}
assert!(
lifted > 0,
"no site's own tau was below the chain's, so this test is not exercising the lift it \
was written for -- pick a colder beta or a more frustrated graph"
);
}
#[test]
fn a_search_set_answers_facts_and_refuses_estimates() {
let set = SampleSet::from_search(
vec![vec![1i8, -1, 1], vec![-1, -1, 1], vec![1, -1, 1]],
vec![-2.0, 0.5, -2.0],
"tabu",
);
assert_eq!(set.best().map(|(_, e)| e), Some(-2.0));
assert_eq!(set.distinct().len(), 2, "three visits, two distinct states");
assert_eq!(set.ground_states(1e-9).len(), 1);
let refused = Refused::NotDistributional { method: "tabu" };
assert_eq!(set.mean_spin(0), Err(refused));
assert_eq!(set.correlation(0, 1), Err(refused));
assert_eq!(set.magnetization(), Err(refused));
assert_eq!(set.mean_energy(), Err(refused));
assert!(format!("{refused}").contains("tabu"), "the refusal must name what refused");
}
#[test]
fn only_a_chain_can_be_certified() {
let g = crate::ising::ring(8, 1.0, 0.0);
let states = vec![vec![1i8; 8]; 40];
let energies = vec![g.energy(&states[0]); 40];
let pop = SampleSet::from_population(states.clone(), energies.clone(), 0.5, 1.2);
assert_eq!(pop.certificate(&g).unwrap_err(), Refused::NotAChain { provenance: "population" });
let enu = enumerate(&g, 0.5).unwrap();
assert_eq!(enu.certificate(&g).unwrap_err(), Refused::NotAChain { provenance: "enumerated" });
let srch = SampleSet::from_search(states.clone(), energies.clone(), "hfs");
assert_eq!(srch.certificate(&g).unwrap_err(), Refused::NotDistributional { method: "hfs" });
let chain = SampleSet::from_chain(states, energies, 0.5, 0, 1);
assert!(chain.certificate(&g).is_ok(), "a chain is exactly what certify takes");
}
#[test]
fn collecting_charges_the_device_for_every_read_as_well_as_every_sweep() {
let g = crate::ising::lattice2d(6, 1.0);
let plan = Plan::new(50, 30, 4);
let mut led = Ledger::default();
let mut smp = Sampler::new(&g, 0.5, 4);
let set = smp.collect(&plan, Some(&mut led));
assert_eq!(set.len(), 30);
assert_eq!(led.samples, plan.sweeps() as u64 * g.n as u64, "one Gibbs cycle per free node per sweep");
assert_eq!(led.reads, 30 * g.n as u64, "one read per node per kept state");
let with = led.joules(&crate::ledger::Z1_SPICE).unwrap();
let sweeps_only =
Ledger { samples: led.samples, reads: 0, writes: 0 }.joules(&crate::ledger::Z1_SPICE).unwrap();
assert!(
with > 3.0 * sweeps_only,
"reads were {with:.3e} J total against {sweeps_only:.3e} J of sampling; if that ratio \
is near one the ledger has stopped pricing readback"
);
}
#[test]
fn the_parallel_path_collects_the_same_way() {
let g = crate::ising::lattice2d(8, 1.0);
let plan = Plan::new(40, 60, 2);
let (mut la, mut lb) = (Ledger::default(), Ledger::default());
let a = Sampler::new(&g, 0.5, 12).collect(&plan, Some(&mut la));
let b = Sampler::new(&g, 0.5, 12).collect_par(&plan, 4, Some(&mut lb));
assert_eq!(la.samples, lb.samples, "same sweeps");
assert_eq!(la.reads, lb.reads, "and the same reads: the parallel path is not cheaper");
assert_eq!(la.reads, 60 * g.n as u64);
assert_eq!(a.len(), b.len());
let (ma, mb) = (a.magnetization().unwrap(), b.magnetization().unwrap());
assert!(ma.value.is_finite() && mb.value.is_finite());
}
#[test]
fn the_same_seed_collects_the_same_states() {
let g = glass(12, 21, 2);
let plan = Plan::new(100, 200, 2);
let a = Sampler::new(&g, 0.7, 5).collect(&plan, None);
let b = Sampler::new(&g, 0.7, 5).collect(&plan, None);
assert_eq!(a.states(), b.states());
assert_eq!(a.energies(), b.energies());
}
#[test]
fn enumeration_counts_the_degeneracy_a_sample_can_only_witness() {
let mut gb = GraphBuilder::new(3);
gb.couple(0, 1, -1.0);
gb.couple(1, 2, -1.0);
gb.couple(0, 2, -1.0);
let g = gb.build();
let enu = enumerate(&g, 2.0).unwrap();
assert_eq!(enu.best().unwrap().1, -1.0);
assert_eq!(enu.ground_states(1e-9).len(), 6, "the frustrated triangle's ground manifold");
let mut smp = Sampler::new(&g, 2.0, 3);
let chain = smp.collect(&Plan::new(200, 400, 1), None);
let witnessed = chain.ground_states(1e-9).len();
assert!(
(1..=6).contains(&witnessed),
"a chain can only report what it saw: {witnessed} states"
);
}
#[test]
fn a_frozen_chain_flags_itself_beside_its_zero_width_interval() {
let mut gb = GraphBuilder::new(6);
for i in 0..6 {
gb.bias(i, 40.0); }
let g = gb.build();
let mut smp = Sampler::new(&g, 2.0, 8);
let set = smp.collect(&Plan::new(100, 400, 1), None);
let e = set.mean_spin(0).unwrap();
assert_eq!(e.value, 1.0);
assert_eq!(e.stderr, 0.0, "a constant observable has zero sample variance");
assert!(
!e.tau_int.is_finite(),
"the zero-width interval is arithmetically right and reads as certainty; the infinite \
tau is the only thing standing between a caller and that reading"
);
assert_eq!(e.ess, 1.0);
assert!(!set.certificate(&g).unwrap().passed(), "and the certificate says so too");
}
#[test]
fn the_first_and_second_moments_agree_with_enumeration() {
let g = glass(12, 44, 2);
let beta = 0.6;
let enu = enumerate(&g, beta).unwrap();
let mut smp = Sampler::new(&g, beta, 17);
let set = smp.collect(&Plan::new(2_000, 8_000, 1), None);
for (i, j) in [(0usize, 1usize), (3, 4), (2, 3), (5, 6)] {
let t = enu.correlation(i, j).unwrap().value;
let e = set.correlation(i, j).unwrap();
assert!(
e.covers(t),
"<s{i} s{j}>: chain {e} does not cover the enumerated {t:.5}"
);
}
let m = set.magnetization().unwrap();
assert!(m.covers(enu.magnetization().unwrap().value), "magnetization: {m}");
}
#[test]
fn enumeration_refuses_a_model_it_cannot_materialise() {
let g = crate::ising::ring(ENUMERATION_LIMIT + 1, 1.0, 0.0);
let err = enumerate(&g, 0.5).unwrap_err();
assert_eq!(
err,
Refused::TooLargeToEnumerate { spins: ENUMERATION_LIMIT + 1, limit: ENUMERATION_LIMIT }
);
assert!(format!("{err}").contains("exact::marginals"), "a refusal should say what to do instead");
}
#[test]
fn a_plan_cannot_thin_by_zero() {
let p = Plan::new(10, 5, 0);
assert_eq!(p.thin, 1, "thinning by zero would return the same state five times");
assert_eq!(p.sweeps(), 15);
}
#[test]
fn mismatched_widths_are_refused_at_construction() {
let r = std::panic::catch_unwind(|| {
SampleSet::from_search(vec![vec![1i8, 1], vec![1i8]], vec![0.0, 0.0], "x")
});
assert!(r.is_err(), "a set whose states have different widths cannot answer mean_spin(i)");
}
}