Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
316
crates/thermorust/tests/correctness.rs
Normal file
316
crates/thermorust/tests/correctness.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
//! 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)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user