Skip to main content

terminals_core/substrate/
thermal.rs

1//! ThermalProjection -- Ising-model spin state for substrate atoms.
2//!
3//! Each atom carries spin (+1/-1), local bias, inverse temperature,
4//! local energy, and running magnetization. Together these enable
5//! Gibbs sampling over the substrate lattice, complementing the
6//! Kuramoto phase oscillator with a thermodynamic ensemble view.
7
8use super::projection::{Projection, ProjectionId};
9
10/// 5 floats: spin, bias, temperature, energy, magnetization = 20 bytes.
11const THERMAL_BYTES: usize = 5 * 4;
12
13/// Ising-model thermal projection for a single ComputeAtom.
14#[derive(Debug, Clone, Copy)]
15pub struct ThermalProjection {
16    /// Spin state: +1.0 or -1.0.
17    pub spin: f32,
18    /// Local field bias b_i.
19    pub bias: f32,
20    /// Inverse temperature beta (higher = more ordered).
21    pub temperature: f32,
22    /// Local energy contribution.
23    pub energy: f32,
24    /// Running magnetization average.
25    pub magnetization: f32,
26}
27
28impl Default for ThermalProjection {
29    fn default() -> Self {
30        Self {
31            spin: 1.0,
32            bias: 0.0,
33            temperature: 1.0,
34            energy: 0.0,
35            magnetization: 0.0,
36        }
37    }
38}
39
40impl Projection for ThermalProjection {
41    fn byte_size() -> usize {
42        THERMAL_BYTES
43    }
44
45    fn id() -> ProjectionId {
46        ProjectionId::Thermal
47    }
48
49    fn read(buf: &[u8]) -> Self {
50        assert!(buf.len() >= THERMAL_BYTES);
51        Self {
52            spin: f32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
53            bias: f32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
54            temperature: f32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]),
55            energy: f32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]),
56            magnetization: f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]),
57        }
58    }
59
60    fn write(&self, buf: &mut [u8]) {
61        assert!(buf.len() >= THERMAL_BYTES);
62        buf[0..4].copy_from_slice(&self.spin.to_le_bytes());
63        buf[4..8].copy_from_slice(&self.bias.to_le_bytes());
64        buf[8..12].copy_from_slice(&self.temperature.to_le_bytes());
65        buf[12..16].copy_from_slice(&self.energy.to_le_bytes());
66        buf[16..20].copy_from_slice(&self.magnetization.to_le_bytes());
67    }
68
69    fn shape_hash_contribution(&self) -> u32 {
70        let mut hash = 0x811c_9dc5u32;
71        for byte in self.spin.to_bits().to_le_bytes() {
72            hash ^= byte as u32;
73            hash = hash.wrapping_mul(0x0100_0193);
74        }
75        for byte in self.magnetization.to_bits().to_le_bytes() {
76            hash ^= byte as u32;
77            hash = hash.wrapping_mul(0x0100_0193);
78        }
79        hash
80    }
81}
82
83/// Perform a deterministic Gibbs step on a single spin site.
84///
85/// Computes the local energy delta for flipping spin_i, then accepts
86/// with probability sigmoid(2 * beta * delta_E). Uses a deterministic
87/// entropy seed instead of random sampling for reproducibility.
88///
89/// # Arguments
90/// * `spin_i` - current spin (+1 or -1)
91/// * `bias_i` - local field at site i
92/// * `neighbor_coupling_sum` - sum over j: J_ij * s_j
93/// * `beta` - inverse temperature
94/// * `entropy_seed` - deterministic seed in [0, 1) for acceptance
95///
96/// # Returns
97/// New spin value (+1.0 or -1.0) and local energy contribution.
98pub fn gibbs_step(
99    _spin_i: f32,
100    bias_i: f32,
101    neighbor_coupling_sum: f32,
102    beta: f32,
103    entropy_seed: f32,
104) -> (f32, f32) {
105    // Local field: h_i = b_i + sum_j J_ij * s_j
106    let local_field = bias_i + neighbor_coupling_sum;
107
108    // Energy contribution for spin_i = +1: E_up = -h_i
109    // Energy contribution for spin_i = -1: E_down = +h_i
110    // P(s_i = +1) = sigmoid(2 * beta * h_i) = 1 / (1 + exp(-2 * beta * h_i))
111    let arg = 2.0 * beta * local_field;
112    let prob_up = 1.0 / (1.0 + (-arg).exp());
113
114    let seed = entropy_seed.clamp(0.0, 0.9999);
115    let new_spin = if seed < prob_up { 1.0 } else { -1.0 };
116
117    // Local energy: -s_i * h_i
118    let energy = -new_spin * local_field;
119
120    (new_spin, energy)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_thermal_byte_size() {
129        assert_eq!(ThermalProjection::byte_size(), 20);
130    }
131
132    #[test]
133    fn test_thermal_roundtrip() {
134        let proj = ThermalProjection {
135            spin: -1.0,
136            bias: 0.5,
137            temperature: 2.0,
138            energy: -0.3,
139            magnetization: 0.7,
140        };
141        let mut buf = vec![0u8; ThermalProjection::byte_size()];
142        proj.write(&mut buf);
143        let restored = ThermalProjection::read(&buf);
144        assert!((restored.spin - (-1.0)).abs() < 1e-6);
145        assert!((restored.bias - 0.5).abs() < 1e-6);
146        assert!((restored.temperature - 2.0).abs() < 1e-6);
147        assert!((restored.energy - (-0.3)).abs() < 1e-6);
148        assert!((restored.magnetization - 0.7).abs() < 1e-6);
149    }
150
151    #[test]
152    fn test_thermal_default() {
153        let proj = ThermalProjection::default();
154        assert!((proj.spin - 1.0).abs() < 1e-6);
155        assert!((proj.bias).abs() < 1e-6);
156        assert!((proj.temperature - 1.0).abs() < 1e-6);
157    }
158
159    #[test]
160    fn test_gibbs_step_strong_field_up() {
161        // Strong positive field at low temperature -> spin should go to +1
162        let (spin, energy) = gibbs_step(1.0, 10.0, 0.0, 5.0, 0.5);
163        assert!((spin - 1.0).abs() < 1e-6, "spin = {}", spin);
164        assert!(energy < 0.0, "energy should be negative for aligned spin");
165    }
166
167    #[test]
168    fn test_gibbs_step_strong_field_down() {
169        // Strong negative field -> spin should go to -1
170        let (spin, _energy) = gibbs_step(1.0, -10.0, 0.0, 5.0, 0.5);
171        assert!((spin - (-1.0)).abs() < 1e-6, "spin = {}", spin);
172    }
173
174    #[test]
175    fn test_gibbs_step_deterministic() {
176        // Same inputs -> same outputs
177        let a = gibbs_step(1.0, 0.5, 0.3, 2.0, 0.4);
178        let b = gibbs_step(1.0, 0.5, 0.3, 2.0, 0.4);
179        assert!((a.0 - b.0).abs() < 1e-6);
180        assert!((a.1 - b.1).abs() < 1e-6);
181    }
182
183    #[test]
184    fn test_gibbs_step_entropy_clamp() {
185        // Seed values at boundaries should not panic
186        let _ = gibbs_step(1.0, 0.0, 0.0, 1.0, 0.0);
187        let _ = gibbs_step(1.0, 0.0, 0.0, 1.0, 1.0);
188        let _ = gibbs_step(1.0, 0.0, 0.0, 1.0, -0.5);
189        let _ = gibbs_step(1.0, 0.0, 0.0, 1.0, 1.5);
190    }
191
192    #[test]
193    fn test_shape_hash_changes_with_state() {
194        let a = ThermalProjection {
195            spin: 1.0,
196            ..Default::default()
197        };
198        let b = ThermalProjection {
199            spin: -1.0,
200            ..Default::default()
201        };
202        assert_ne!(a.shape_hash_contribution(), b.shape_hash_contribution());
203    }
204}