Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'

This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
7854 changed files with 3522914 additions and 0 deletions

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