Squashed 'vendor/ruvector/' content from commit b64c2172

git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
commit d803bfe2b1
7854 changed files with 3522914 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
[package]
name = "thermorust"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["rUv <ruv@ruv.io>"]
repository = "https://github.com/ruvnet/ruvector"
homepage = "https://ruv.io"
documentation = "https://docs.rs/thermorust"
description = "Thermodynamic neural motif engine: energy-driven state transitions with Landauer dissipation and Langevin noise"
keywords = ["thermodynamics", "neural", "ising", "langevin", "physics"]
categories = ["science", "algorithms", "simulation"]
readme = "README.md"
[dependencies]
rand = { version = "0.8", features = ["small_rng"] }
rand_distr = "0.4"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "motif_bench"
harness = false

View File

@@ -0,0 +1,71 @@
# thermorust
A minimal thermodynamic neural-motif engine for Rust. Treats computation as
**energy-driven state transitions** with Landauer-style dissipation tracking
and Langevin/Metropolis noise baked in.
## Features
- **Ising and soft-spin Hamiltonians** with configurable coupling matrices and local fields.
- **Metropolis-Hastings** (discrete) and **overdamped Langevin** (continuous) dynamics.
- **Landauer dissipation accounting** -- every accepted irreversible transition charges
kT ln 2 of heat, giving a physical energy audit of your computation.
- **Langevin and Poisson spike noise** sources satisfying the fluctuation-dissipation theorem.
- **Thermodynamic observables** -- magnetisation, pattern overlap, binary entropy,
free energy, and running energy/dissipation traces.
- **Pre-wired motif factories** -- ring, fully-connected, Hopfield memory, and
random soft-spin networks ready to simulate out of the box.
- **Simulated annealing** helpers for both discrete and continuous models.
## Quick start
```rust
use thermorust::{motifs::IsingMotif, dynamics::{Params, anneal_discrete}};
use rand::SeedableRng;
let mut motif = IsingMotif::ring(16, 0.2);
let params = Params::default_n(16);
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let trace = anneal_discrete(
&motif.model, &mut motif.state, &params, 10_000, 100, &mut rng,
);
println!("Mean energy: {:.3}", trace.mean_energy());
println!("Heat shed: {:.3e} J", trace.total_dissipation());
```
### Continuous soft-spin simulation
```rust
use thermorust::{motifs::SoftSpinMotif, dynamics::{Params, anneal_continuous}};
use rand::SeedableRng;
let mut motif = SoftSpinMotif::random(32, 1.0, 0.5, 42);
let params = Params::default_n(32);
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
let trace = anneal_continuous(
&motif.model, &mut motif.state, &params, 5_000, 50, &mut rng,
);
```
## Modules
| Module | Description |
|--------|-------------|
| `state` | `State` -- activation vector with cumulative dissipation counter |
| `energy` | `EnergyModel` trait, `Ising`, `SoftSpin`, `Couplings` |
| `dynamics` | `step_discrete` (MH), `step_continuous` (Langevin), annealers |
| `noise` | Langevin Gaussian and Poisson spike noise sources |
| `metrics` | Magnetisation, overlap, entropy, free energy, `Trace` |
| `motifs` | Pre-wired ring, fully-connected, Hopfield, and soft-spin motifs |
## Dependencies
- `rand` 0.8 (with `small_rng`)
- `rand_distr` 0.4
## License
Licensed under either of [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)
or [MIT License](http://opensource.org/licenses/MIT) at your option.

View File

@@ -0,0 +1,98 @@
//! Criterion microbenchmarks for thermorust motifs.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use rand::SeedableRng;
use thermorust::{
dynamics::{anneal_continuous, anneal_discrete, step_discrete, Params},
energy::{Couplings, EnergyModel, Ising},
motifs::{IsingMotif, SoftSpinMotif},
State,
};
fn bench_discrete_step(c: &mut Criterion) {
let mut group = c.benchmark_group("step_discrete");
for n in [8, 16, 32] {
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| {
let model = Ising::new(Couplings::ferromagnetic_ring(n, 0.2));
let p = Params::default_n(n);
let mut s = State::ones(n);
let mut rng = rand::rngs::SmallRng::seed_from_u64(1);
b.iter(|| {
step_discrete(
black_box(&model),
black_box(&mut s),
black_box(&p),
&mut rng,
);
});
});
}
group.finish();
}
fn bench_10k_steps(c: &mut Criterion) {
let mut group = c.benchmark_group("10k_steps");
for n in [16, 32] {
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| {
b.iter(|| {
let mut motif = IsingMotif::ring(n, 0.2);
let p = Params::default_n(n);
let mut rng = rand::rngs::SmallRng::seed_from_u64(123);
let trace = anneal_discrete(
black_box(&motif.model),
black_box(&mut motif.state),
black_box(&p),
black_box(10_000),
0,
&mut rng,
);
black_box(motif.state.dissipated_j)
});
});
}
group.finish();
}
fn bench_langevin_10k(c: &mut Criterion) {
let mut group = c.benchmark_group("langevin_10k");
for n in [8, 16] {
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| {
b.iter(|| {
let mut motif = SoftSpinMotif::random(n, 1.0, 0.5, 42);
let p = Params::default_n(n);
let mut rng = rand::rngs::SmallRng::seed_from_u64(77);
anneal_continuous(
black_box(&motif.model),
black_box(&mut motif.state),
black_box(&p),
black_box(10_000),
0,
&mut rng,
);
black_box(motif.state.dissipated_j)
});
});
}
group.finish();
}
fn bench_energy_evaluation(c: &mut Criterion) {
let mut group = c.benchmark_group("energy_eval");
for n in [8, 16, 32] {
let model = Ising::new(Couplings::ferromagnetic_ring(n, 0.2));
let s = State::ones(n);
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| {
b.iter(|| black_box(model.energy(black_box(&s))));
});
}
group.finish();
}
criterion_group!(
benches,
bench_discrete_step,
bench_10k_steps,
bench_langevin_10k,
bench_energy_evaluation,
);
criterion_main!(benches);

View File

@@ -0,0 +1,163 @@
//! Stochastic dynamics: Metropolis-Hastings (discrete) and overdamped Langevin (continuous).
use crate::energy::EnergyModel;
use crate::noise::{langevin_noise, poisson_spike};
use crate::state::State;
use rand::Rng;
/// Parameters governing thermal dynamics and Landauer dissipation accounting.
#[derive(Clone, Debug)]
pub struct Params {
/// Inverse temperature β = 1/(kT). Higher β → colder, less noise.
pub beta: f32,
/// Step size η for continuous (Langevin) updates.
pub eta: f32,
/// Joules of heat attributed to each accepted irreversible transition.
/// Landauer's limit: kT ln2 ≈ 2.87 × 10⁻²¹ J at 300 K.
pub irreversible_cost: f64,
/// Which unit indices are clamped (fixed inputs).
pub clamp_mask: Vec<bool>,
}
impl Params {
/// Sensible defaults: room-temperature Landauer limit, no clamping.
pub fn default_n(n: usize) -> Self {
Self {
beta: 2.0,
eta: 0.05,
irreversible_cost: 2.87e-21, // kT ln2 at 300 K in Joules
clamp_mask: vec![false; n],
}
}
#[inline]
fn is_clamped(&self, i: usize) -> bool {
self.clamp_mask.get(i).copied().unwrap_or(false)
}
}
/// **Metropolis-Hastings** single spin-flip update for *discrete* Ising states.
///
/// Proposes flipping spin `i` (chosen uniformly at random), accepts with the
/// Boltzmann probability, and charges `p.irreversible_cost` on each accepted
/// non-zero-ΔE transition.
pub fn step_discrete<M: EnergyModel>(model: &M, s: &mut State, p: &Params, rng: &mut impl Rng) {
let n = s.x.len();
if n == 0 {
return;
}
let i: usize = rng.gen_range(0..n);
if p.is_clamped(i) {
return;
}
let old_e = model.energy(s);
let old_si = s.x[i];
s.x[i] = -old_si;
let new_e = model.energy(s);
let d_e = (new_e - old_e) as f64;
let accept = d_e <= 0.0 || {
let prob = (-p.beta as f64 * d_e).exp();
rng.gen::<f64>() < prob
};
if accept {
if d_e != 0.0 {
s.dissipated_j += p.irreversible_cost;
}
} else {
s.x[i] = old_si;
}
}
/// **Overdamped Langevin** update for *continuous* activations.
///
/// For each unclamped unit `i`:
/// xᵢ ← xᵢ η · ∂H/∂xᵢ + √(2/β) · ξ
/// where ξ ~ N(0,1). The gradient is estimated by central differences.
///
/// Optionally clips activations to `[-1, 1]` after the update.
pub fn step_continuous<M: EnergyModel>(model: &M, s: &mut State, p: &Params, rng: &mut impl Rng) {
let n = s.x.len();
let eps = 1e-3_f32;
for i in 0..n {
if p.is_clamped(i) {
continue;
}
let old = s.x[i];
// Central-difference gradient ∂H/∂xᵢ
s.x[i] = old + eps;
let e_plus = model.energy(s);
s.x[i] = old - eps;
let e_minus = model.energy(s);
s.x[i] = old;
let grad = (e_plus - e_minus) / (2.0 * eps);
let noise = langevin_noise(p.beta, rng);
let dx = -p.eta * grad + noise;
let old_e = model.energy(s);
s.x[i] = (old + dx).clamp(-1.0, 1.0);
let new_e = model.energy(s);
if (new_e as f64) < (old_e as f64) {
s.dissipated_j += p.irreversible_cost;
}
}
}
/// Run `steps` discrete Metropolis updates, recording every `record_every`th
/// step into the optional `trace`.
pub fn anneal_discrete<M: EnergyModel>(
model: &M,
s: &mut State,
p: &Params,
steps: usize,
record_every: usize,
rng: &mut impl Rng,
) -> crate::metrics::Trace {
let mut trace = crate::metrics::Trace::new();
for step in 0..steps {
step_discrete(model, s, p, rng);
if record_every > 0 && step % record_every == 0 {
trace.push(model.energy(s), s.dissipated_j);
}
}
trace
}
/// Run `steps` Langevin updates, recording every `record_every`th step.
pub fn anneal_continuous<M: EnergyModel>(
model: &M,
s: &mut State,
p: &Params,
steps: usize,
record_every: usize,
rng: &mut impl Rng,
) -> crate::metrics::Trace {
let mut trace = crate::metrics::Trace::new();
for step in 0..steps {
step_continuous(model, s, p, rng);
if record_every > 0 && step % record_every == 0 {
trace.push(model.energy(s), s.dissipated_j);
}
}
trace
}
/// Inject Poisson spike noise into `s`, bypassing thermal Boltzmann acceptance.
///
/// Each unit has an independent probability `rate` (per step) of receiving a
/// kick of magnitude `kick`, with a random sign.
pub fn inject_spikes(s: &mut State, p: &Params, rate: f64, kick: f32, rng: &mut impl Rng) {
for (i, xi) in s.x.iter_mut().enumerate() {
if p.is_clamped(i) {
continue;
}
let dk = poisson_spike(rate, kick, rng);
*xi = (*xi + dk).clamp(-1.0, 1.0);
}
}

View File

@@ -0,0 +1,126 @@
//! Energy models: Ising/Hopfield Hamiltonian and the `EnergyModel` trait.
use crate::state::State;
/// Coupling weights and local fields for a fully-connected motif.
///
/// `j` is a flattened row-major `n×n` symmetric matrix; `h` is the `n`-vector
/// of local (bias) fields.
#[derive(Clone, Debug)]
pub struct Couplings {
/// Symmetric coupling matrix J_ij (row-major, length n²).
pub j: Vec<f32>,
/// Local field h_i (length n).
pub h: Vec<f32>,
}
impl Couplings {
/// Build zero-coupling weights for `n` units.
pub fn zeros(n: usize) -> Self {
Self {
j: vec![0.0; n * n],
h: vec![0.0; n],
}
}
/// Build ferromagnetic ring couplings: J_{i, i+1} = strength.
pub fn ferromagnetic_ring(n: usize, strength: f32) -> Self {
let mut j = vec![0.0; n * n];
for i in 0..n {
let next = (i + 1) % n;
j[i * n + next] = strength;
j[next * n + i] = strength;
}
Self { j, h: vec![0.0; n] }
}
/// Build random Hopfield memory couplings from a list of patterns.
///
/// Patterns should be `±1` binary vectors of length `n`.
pub fn hopfield_memory(n: usize, patterns: &[Vec<f32>]) -> Self {
let mut j = vec![0.0f32; n * n];
let scale = 1.0 / n as f32;
for pat in patterns {
assert_eq!(pat.len(), n, "pattern length must equal n");
for i in 0..n {
for k in (i + 1)..n {
let dj = scale * pat[i] * pat[k];
j[i * n + k] += dj;
j[k * n + i] += dj;
}
}
}
Self { j, h: vec![0.0; n] }
}
}
/// Trait implemented by any Hamiltonian that can return a scalar energy.
pub trait EnergyModel {
/// Compute the total energy of `state`.
fn energy(&self, state: &State) -> f32;
}
/// Ising/Hopfield Hamiltonian:
/// H = −Σᵢ hᵢ xᵢ Σᵢ<ⱼ Jᵢⱼ xᵢ xⱼ
#[derive(Clone, Debug)]
pub struct Ising {
pub c: Couplings,
}
impl Ising {
pub fn new(c: Couplings) -> Self {
Self { c }
}
}
impl EnergyModel for Ising {
fn energy(&self, s: &State) -> f32 {
let n = s.x.len();
debug_assert_eq!(self.c.h.len(), n);
let mut e = 0.0_f32;
for i in 0..n {
e -= self.c.h[i] * s.x[i];
for j in (i + 1)..n {
e -= self.c.j[i * n + j] * s.x[i] * s.x[j];
}
}
e
}
}
/// Soft-spin (XY-like) model with continuous activations.
///
/// Adds a quartic double-well self-energy per unit: a·x² + b·x⁴
/// which promotes ±1 attractors.
#[derive(Clone, Debug)]
pub struct SoftSpin {
pub c: Couplings,
/// Well depth coefficient (>0 pushes spins toward ±1).
pub a: f32,
/// Quartic stiffness (>0 keeps spins bounded).
pub b: f32,
}
impl SoftSpin {
pub fn new(c: Couplings, a: f32, b: f32) -> Self {
Self { c, a, b }
}
}
impl EnergyModel for SoftSpin {
fn energy(&self, s: &State) -> f32 {
let n = s.x.len();
let mut e = 0.0_f32;
for i in 0..n {
let xi = s.x[i];
// Double-well self-energy
e += -self.a * xi * xi + self.b * xi * xi * xi * xi;
// Local field
e -= self.c.h[i] * xi;
for j in (i + 1)..n {
e -= self.c.j[i * n + j] * xi * s.x[j];
}
}
e
}
}

View File

@@ -0,0 +1,45 @@
//! # thermorust
//!
//! A minimal thermodynamic neural-motif crate for Rust.
//!
//! Treats computation as **energy-driven state transitions** with
//! Landauer-style dissipation and Langevin/Metropolis noise baked in.
//!
//! ## Core abstractions
//!
//! | Module | What it provides |
//! |--------|-----------------|
//! | [`state`] | `State` activation vector + dissipated-joules counter |
//! | [`energy`] | `EnergyModel` trait, `Ising`, `SoftSpin`, `Couplings` |
//! | [`dynamics`] | `step_discrete` (MH), `step_continuous` (Langevin), annealers |
//! | [`noise`] | Langevin & Poisson spike noise sources |
//! | [`metrics`] | Magnetisation, overlap, entropy, free energy, `Trace` |
//! | [`motifs`] | Pre-wired ring / fully-connected / Hopfield / soft-spin motifs |
//!
//! ## Quick start
//!
//! ```no_run
//! use thermorust::{motifs::IsingMotif, dynamics::{Params, anneal_discrete}};
//! use rand::SeedableRng;
//!
//! let mut motif = IsingMotif::ring(16, 0.2);
//! let params = Params::default_n(16);
//! let mut rng = rand::rngs::StdRng::seed_from_u64(42);
//!
//! let trace = anneal_discrete(&motif.model, &mut motif.state, &params, 10_000, 100, &mut rng);
//! println!("Mean energy: {:.3}", trace.mean_energy());
//! println!("Heat shed: {:.3e} J", trace.total_dissipation());
//! ```
pub mod dynamics;
pub mod energy;
pub mod metrics;
pub mod motifs;
pub mod noise;
pub mod state;
// Re-export the most commonly used items at the crate root.
pub use dynamics::{anneal_continuous, anneal_discrete, step_continuous, step_discrete, Params};
pub use energy::{Couplings, EnergyModel, Ising, SoftSpin};
pub use metrics::{magnetisation, overlap, Trace};
pub use state::State;

View File

@@ -0,0 +1,95 @@
//! Thermodynamic observables: magnetisation, entropy, free energy, overlap.
use crate::state::State;
/// Mean magnetisation: m = (1/n) Σᵢ xᵢ ∈ [1, 1].
pub fn magnetisation(s: &State) -> f32 {
if s.x.is_empty() {
return 0.0;
}
s.x.iter().sum::<f32>() / s.x.len() as f32
}
/// Mean-squared activation: ⟨x²⟩.
pub fn mean_sq(s: &State) -> f32 {
if s.x.is_empty() {
return 0.0;
}
s.x.iter().map(|xi| xi * xi).sum::<f32>() / s.x.len() as f32
}
/// Pattern overlap (Hopfield order parameter):
/// m_μ = (1/n) Σᵢ ξᵢ^μ xᵢ
///
/// Returns `None` if lengths differ.
pub fn overlap(s: &State, pattern: &[f32]) -> Option<f32> {
let n = s.x.len();
if pattern.len() != n || n == 0 {
return None;
}
let sum: f32 = s.x.iter().zip(pattern.iter()).map(|(xi, pi)| xi * pi).sum();
Some(sum / n as f32)
}
/// Approximate configurational entropy (binary case) via:
/// S ≈ n [ p ln p + (1p) ln(1p) ]
/// where p = fraction of spins at +1.
///
/// Returns 0 for edge cases (all ±1).
pub fn binary_entropy(s: &State) -> f32 {
let n = s.x.len();
if n == 0 {
return 0.0;
}
let p_up = s.x.iter().filter(|&&xi| xi > 0.0).count() as f32 / n as f32;
let p_dn = 1.0 - p_up;
let h = |p: f32| {
if p <= 0.0 || p >= 1.0 {
0.0
} else {
-p * p.ln() - (1.0 - p) * (1.0 - p).ln()
}
};
n as f32 * h(p_up) * 0.5 + n as f32 * h(p_dn) * 0.5
}
/// Estimate free energy: F ≈ E T·S = E S/β.
///
/// `energy` should be `model.energy(s)`.
pub fn free_energy(energy: f32, entropy: f32, beta: f32) -> f32 {
energy - entropy / beta
}
/// Running statistics accumulator for energy / dissipation traces.
#[derive(Default, Debug, Clone)]
pub struct Trace {
/// Energy samples (one per recorded step).
pub energies: Vec<f32>,
/// Cumulative dissipation at each recorded step.
pub dissipation: Vec<f64>,
}
impl Trace {
pub fn new() -> Self {
Self::default()
}
/// Record one observation.
pub fn push(&mut self, energy: f32, dissipated_j: f64) {
self.energies.push(energy);
self.dissipation.push(dissipated_j);
}
/// Mean energy over all recorded steps.
pub fn mean_energy(&self) -> f32 {
if self.energies.is_empty() {
return 0.0;
}
self.energies.iter().sum::<f32>() / self.energies.len() as f32
}
/// Total heat shed over all steps.
pub fn total_dissipation(&self) -> f64 {
self.dissipation.last().copied().unwrap_or(0.0)
}
}

View File

@@ -0,0 +1,77 @@
//! Pre-wired motif factories: ring, fully-connected, and Hopfield memory nets.
use crate::energy::{Couplings, Ising, SoftSpin};
use crate::state::State;
/// A self-contained motif: initial state + Ising Hamiltonian + default params.
pub struct IsingMotif {
pub state: State,
pub model: Ising,
}
impl IsingMotif {
/// Ferromagnetic ring of `n` spins. J_{i,i+1} = `strength`.
pub fn ring(n: usize, strength: f32) -> Self {
Self {
state: State::ones(n),
model: Ising::new(Couplings::ferromagnetic_ring(n, strength)),
}
}
/// Fully connected ferromagnet: J_ij = strength for all i≠j.
pub fn fully_connected(n: usize, strength: f32) -> Self {
let mut j = vec![0.0_f32; n * n];
for i in 0..n {
for k in (i + 1)..n {
j[i * n + k] = strength;
j[k * n + i] = strength;
}
}
Self {
state: State::ones(n),
model: Ising::new(Couplings { j, h: vec![0.0; n] }),
}
}
/// Hopfield associative memory loaded with `patterns` (±1 binary vectors).
pub fn hopfield(n: usize, patterns: &[Vec<f32>]) -> Self {
Self {
state: State::ones(n),
model: Ising::new(Couplings::hopfield_memory(n, patterns)),
}
}
}
/// Soft-spin motif with double-well on-site potential for continuous activations.
pub struct SoftSpinMotif {
pub state: State,
pub model: SoftSpin,
}
impl SoftSpinMotif {
/// Random-coupling soft-spin motif seeded with `seed`.
pub fn random(n: usize, a: f32, b: f32, seed: u64) -> Self {
use rand::{Rng, SeedableRng};
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
let j: Vec<f32> = (0..n * n).map(|_| rng.gen_range(-0.5_f32..0.5)).collect();
// Symmetrise
let mut j_sym = vec![0.0_f32; n * n];
for i in 0..n {
for k in 0..n {
j_sym[i * n + k] = (j[i * n + k] + j[k * n + i]) * 0.5;
}
}
let x: Vec<f32> = (0..n).map(|_| rng.gen_range(-0.1_f32..0.1)).collect();
Self {
state: State::from_vec(x),
model: SoftSpin::new(
Couplings {
j: j_sym,
h: vec![0.0; n],
},
a,
b,
),
}
}
}

View File

@@ -0,0 +1,48 @@
//! Thermal noise sources: Gaussian (Langevin) and Poisson spike noise.
use rand::Rng;
use rand_distr::{Distribution, Normal, Poisson};
/// Draw a Gaussian noise sample with standard deviation σ = √(2/β).
///
/// This matches the fluctuation-dissipation theorem for overdamped Langevin:
/// the noise amplitude must be √(2kT) = √(2/β) in dimensionless units.
#[inline]
pub fn langevin_noise(beta: f32, rng: &mut impl Rng) -> f32 {
if beta <= 0.0 || !beta.is_finite() {
return 0.0;
}
let sigma = (2.0 / beta).sqrt();
Normal::new(0.0_f32, sigma)
.unwrap_or_else(|_| Normal::new(0.0_f32, 1e-6).unwrap())
.sample(rng)
}
/// Draw `n` independent Langevin noise samples.
pub fn langevin_noise_vec(beta: f32, n: usize, rng: &mut impl Rng) -> Vec<f32> {
if beta <= 0.0 || !beta.is_finite() {
return vec![0.0; n];
}
let sigma = (2.0 / beta).sqrt();
let dist = Normal::new(0.0_f32, sigma).unwrap_or_else(|_| Normal::new(0.0_f32, 1e-6).unwrap());
(0..n).map(|_| dist.sample(rng)).collect()
}
/// Poisson spike noise: add a random kick of magnitude `kick` with rate λ.
///
/// Returns the kick to add to a single activation (0.0 if no spike this step).
#[inline]
pub fn poisson_spike(rate: f64, kick: f32, rng: &mut impl Rng) -> f32 {
if rate <= 0.0 || !rate.is_finite() {
return 0.0;
}
let dist = Poisson::new(rate).unwrap_or_else(|_| Poisson::new(1e-6).unwrap());
let count = dist.sample(rng) as u64;
if count > 0 {
// Random sign
let sign = if rng.gen::<bool>() { 1.0 } else { -1.0 };
sign * kick * count as f32
} else {
0.0
}
}

View File

@@ -0,0 +1,59 @@
//! System state: continuous activations or binary spins with dissipation bookkeeping.
/// State of a thermodynamic motif.
///
/// Activations are stored as `f32` in `[-1.0, 1.0]` (or `{-1.0, +1.0}` for
/// discrete Ising spins). `dissipated_j` accumulates the total Joules of heat
/// shed over all accepted irreversible transitions.
#[derive(Clone, Debug)]
pub struct State {
/// Spin / activation vector.
pub x: Vec<f32>,
/// Cumulative heat dissipated (Joules).
pub dissipated_j: f64,
}
impl State {
/// Construct a new state with all spins set to `+1`.
pub fn ones(n: usize) -> Self {
Self {
x: vec![1.0; n],
dissipated_j: 0.0,
}
}
/// Construct a new state with all spins set to `-1`.
pub fn neg_ones(n: usize) -> Self {
Self {
x: vec![-1.0; n],
dissipated_j: 0.0,
}
}
/// Construct a state from an explicit activation vector.
pub fn from_vec(x: Vec<f32>) -> Self {
Self {
x,
dissipated_j: 0.0,
}
}
/// Number of units in the motif.
#[inline]
pub fn len(&self) -> usize {
self.x.len()
}
/// True if the motif has no units.
#[inline]
pub fn is_empty(&self) -> bool {
self.x.is_empty()
}
/// Clamp all activations to `[-1.0, 1.0]`.
pub fn clamp(&mut self) {
for xi in &mut self.x {
*xi = xi.clamp(-1.0, 1.0);
}
}
}

View 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)"
);
}