Files
wifi-densepose/crates/thermorust/tests/correctness.rs
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

317 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Correctness and invariant tests for thermorust.
use rand::SeedableRng;
use thermorust::{
dynamics::{anneal_continuous, anneal_discrete, inject_spikes, step_discrete, Params},
energy::{Couplings, EnergyModel, Ising},
metrics::{binary_entropy, magnetisation, overlap},
motifs::IsingMotif,
State,
};
// ── Helpers ──────────────────────────────────────────────────────────────────
fn rng(seed: u64) -> rand::rngs::StdRng {
rand::rngs::StdRng::seed_from_u64(seed)
}
fn ring_ising(n: usize) -> Ising {
Ising::new(Couplings::ferromagnetic_ring(n, 0.2))
}
// ── Energy model ─────────────────────────────────────────────────────────────
#[test]
fn all_up_ring_energy_is_negative() {
let n = 8;
let model = ring_ising(n);
let s = State::ones(n);
let e = model.energy(&s);
// For a ferromagnetic ring with J=0.2, all-up: E = n * 0.2
assert!(
e < 0.0,
"ferromagnetic ring energy should be negative for aligned spins: {e}"
);
}
#[test]
fn antiferromagnetic_ring_energy_is_positive() {
let n = 8;
// Antiferromagnetic: J = 0.2
let j: Vec<f32> = {
let mut v = vec![0.0; n * n];
for i in 0..n {
let nxt = (i + 1) % n;
v[i * n + nxt] = -0.2;
v[nxt * n + i] = -0.2;
}
v
};
let model = Ising::new(Couplings { j, h: vec![0.0; n] });
let s = State::ones(n); // all-up is frustrated for antiferromagnet
let e = model.energy(&s);
assert!(
e > 0.0,
"antiferromagnetic all-up energy should be positive: {e}"
);
}
#[test]
fn energy_is_symmetric_under_global_flip() {
let n = 12;
let model = ring_ising(n);
let s_up = State::ones(n);
let s_dn = State::neg_ones(n);
let e_up = model.energy(&s_up);
let e_dn = model.energy(&s_dn);
assert!(
(e_up - e_dn).abs() < 1e-5,
"energy must be Z₂-symmetric: {e_up} vs {e_dn}"
);
}
// ── Metropolis dynamics ───────────────────────────────────────────────────────
#[test]
fn energy_should_drop_over_many_steps() {
let n = 16;
let mut s = State::from_vec(
// Frustrate the ring: alternating signs
(0..n)
.map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
.collect(),
);
let model = ring_ising(n);
let p = Params::default_n(n);
let e0 = model.energy(&s);
let mut rng = rng(42);
for _ in 0..20_000 {
step_discrete(&model, &mut s, &p, &mut rng);
}
let e1 = model.energy(&s);
assert!(
e1 <= e0 + 1e-3,
"energy should not increase long-run: {e1} > {e0}"
);
assert!(
s.dissipated_j > 0.0,
"at least some heat must have been shed"
);
}
#[test]
fn clamped_units_do_not_change() {
let mut s = State::from_vec(vec![1.0, -1.0, 1.0]);
let model = Ising::new(Couplings::zeros(3));
let mut p = Params::default_n(3);
p.clamp_mask = vec![true, false, true];
let mut rng = rng(7);
let before = s.x.clone();
for _ in 0..5_000 {
step_discrete(&model, &mut s, &p, &mut rng);
}
assert_eq!(s.x[0], before[0], "clamped spin 0 must not change");
assert_eq!(s.x[2], before[2], "clamped spin 2 must not change");
}
#[test]
fn hot_system_ergodically_explores_both_states() {
// Very high temperature (β=0.01) → nearly random walk; should visit ±1.
let n = 4;
let model = ring_ising(n);
let mut p = Params::default_n(n);
p.beta = 0.01;
let mut s = State::ones(n);
let mut rng = rng(99);
let mut saw_negative = false;
for _ in 0..50_000 {
step_discrete(&model, &mut s, &p, &mut rng);
if s.x.iter().any(|&xi| xi < 0.0) {
saw_negative = true;
break;
}
}
assert!(saw_negative, "hot system must flip at least one spin");
}
#[test]
fn cold_system_stays_near_ground_state() {
// Very low temperature (β=20) → nearly greedy; aligned ring should stay aligned.
let n = 8;
let model = ring_ising(n);
let mut p = Params::default_n(n);
p.beta = 20.0;
let mut s = State::ones(n);
let mut rng = rng(55);
for _ in 0..5_000 {
step_discrete(&model, &mut s, &p, &mut rng);
}
let m = magnetisation(&s);
assert!(m > 0.9, "cold ferromagnet should stay ordered: m={m}");
}
// ── Langevin dynamics ─────────────────────────────────────────────────────────
#[test]
fn langevin_lowers_energy_on_average() {
use thermorust::motifs::SoftSpinMotif;
let n = 8;
let mut motif = SoftSpinMotif::random(n, 1.0, 0.5, 13);
let p = Params::default_n(n);
let e0 = motif.model.energy(&motif.state);
let mut rng = rng(101);
let trace = anneal_continuous(&motif.model, &mut motif.state, &p, 5_000, 50, &mut rng);
let e_last = *trace.energies.last().unwrap();
// Allow small positive excursions due to noise, but mean should be ≤ e0
assert!(
trace.mean_energy() <= e0 + 0.5,
"Langevin annealing mean energy {:.3} should not exceed initial {:.3}",
trace.mean_energy(),
e0
);
let _ = e_last; // suppress unused warning
}
#[test]
fn langevin_keeps_activations_in_bounds() {
use thermorust::motifs::SoftSpinMotif;
let n = 16;
let mut motif = SoftSpinMotif::random(n, 1.0, 0.5, 77);
let p = Params::default_n(n);
let mut rng = rng(202);
anneal_continuous(&motif.model, &mut motif.state, &p, 3_000, 0, &mut rng);
for xi in &motif.state.x {
assert!(xi.abs() <= 1.0, "activation out of bounds: {xi}");
}
}
// ── Anneal helpers ────────────────────────────────────────────────────────────
#[test]
fn anneal_discrete_trace_has_correct_length() {
let n = 8;
let mut motif = IsingMotif::ring(n, 0.3);
let p = Params::default_n(n);
let mut rng = rng(33);
let trace = anneal_discrete(&motif.model, &mut motif.state, &p, 1_000, 10, &mut rng);
// 1000 steps / record_every=10 → 100 samples (steps 0,10,20,…,990)
assert_eq!(trace.energies.len(), 100);
assert_eq!(trace.dissipation.len(), 100);
}
#[test]
fn dissipation_monotonically_non_decreasing() {
let n = 8;
let mut motif = IsingMotif::ring(n, 0.3);
let p = Params::default_n(n);
let mut rng = rng(44);
let trace = anneal_discrete(&motif.model, &mut motif.state, &p, 2_000, 20, &mut rng);
for w in trace.dissipation.windows(2) {
assert!(
w[1] >= w[0],
"dissipation must be non-decreasing: {} < {}",
w[1],
w[0]
);
}
}
// ── Spike injection ───────────────────────────────────────────────────────────
#[test]
fn spike_injection_does_not_move_clamped_spins() {
let mut s = State::from_vec(vec![1.0, 0.5, -1.0, 0.0]);
let mut p = Params::default_n(4);
p.clamp_mask = vec![true, false, true, false];
let before = s.x.clone();
let mut rng = rng(66);
for _ in 0..100 {
inject_spikes(&mut s, &p, 0.5, 0.3, &mut rng);
}
assert_eq!(s.x[0], before[0]);
assert_eq!(s.x[2], before[2]);
}
// ── Metrics ───────────────────────────────────────────────────────────────────
#[test]
fn magnetisation_all_up_is_one() {
let s = State::ones(16);
assert!((magnetisation(&s) - 1.0).abs() < 1e-6);
}
#[test]
fn magnetisation_all_down_is_minus_one() {
let s = State::neg_ones(16);
assert!((magnetisation(&s) + 1.0).abs() < 1e-6);
}
#[test]
fn overlap_with_self_is_one() {
let s = State::ones(8);
let pat = vec![1.0_f32; 8];
let m = overlap(&s, &pat).unwrap();
assert!(
(m - 1.0).abs() < 1e-6,
"overlap with self should be 1.0: {m}"
);
}
#[test]
fn overlap_mismatched_length_is_none() {
let s = State::ones(4);
let pat = vec![1.0_f32; 8];
assert!(overlap(&s, &pat).is_none());
}
#[test]
fn binary_entropy_max_at_half_half() {
// Half +1, half -1 → maximum entropy
let x = (0..16)
.map(|i| if i < 8 { 1.0_f32 } else { -1.0 })
.collect();
let s = State::from_vec(x);
let h = binary_entropy(&s);
assert!(h > 0.0, "entropy of mixed state must be positive: {h}");
}
#[test]
fn binary_entropy_zero_for_pure_state() {
let s = State::ones(16);
let h = binary_entropy(&s);
// All spins up → p=1, entropy=0
assert!(h.abs() < 1e-5, "entropy of pure state should be 0: {h}");
}
// ── Hopfield memory ───────────────────────────────────────────────────────────
#[test]
fn hopfield_retrieves_stored_pattern() {
let n = 20;
let pattern: Vec<f32> = (0..n)
.map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
.collect();
let motif = IsingMotif::hopfield(n, &[pattern.clone()]);
let mut p = Params::default_n(n);
p.beta = 10.0; // cold
// Start with noisy version (5 bits flipped)
let mut noisy = pattern.clone();
for i in 0..5 {
noisy[i] = -noisy[i];
}
let mut s = State::from_vec(noisy);
let mut rng = rng(88);
for _ in 0..50_000 {
step_discrete(&motif.model, &mut s, &p, &mut rng);
}
let m = overlap(&s, &pattern).unwrap().abs();
assert!(
m > 0.7,
"Hopfield net should retrieve stored pattern (overlap {m:.3} < 0.7)"
);
}