Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
24
crates/thermorust/Cargo.toml
Normal file
24
crates/thermorust/Cargo.toml
Normal 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
|
||||
71
crates/thermorust/README.md
Normal file
71
crates/thermorust/README.md
Normal 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, ¶ms, 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, ¶ms, 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.
|
||||
98
crates/thermorust/benches/motif_bench.rs
Normal file
98
crates/thermorust/benches/motif_bench.rs
Normal 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);
|
||||
163
crates/thermorust/src/dynamics.rs
Normal file
163
crates/thermorust/src/dynamics.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
126
crates/thermorust/src/energy.rs
Normal file
126
crates/thermorust/src/energy.rs
Normal 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
|
||||
}
|
||||
}
|
||||
45
crates/thermorust/src/lib.rs
Normal file
45
crates/thermorust/src/lib.rs
Normal 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, ¶ms, 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;
|
||||
95
crates/thermorust/src/metrics.rs
Normal file
95
crates/thermorust/src/metrics.rs
Normal 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 + (1−p) ln(1−p) ]
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
77
crates/thermorust/src/motifs.rs
Normal file
77
crates/thermorust/src/motifs.rs
Normal 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,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
48
crates/thermorust/src/noise.rs
Normal file
48
crates/thermorust/src/noise.rs
Normal 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
|
||||
}
|
||||
}
|
||||
59
crates/thermorust/src/state.rs
Normal file
59
crates/thermorust/src/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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