Skip to main content

terminals_core/substrate/
wasm_api.rs

1//! SubstrateEngine — Monomorphized API for WASM consumption.
2//!
3//! This module pins all generic substrate operations to concrete types
4//! so the wasm-bindgen boundary only sees f32/u32/String. The engine
5//! owns its atom buffer and exposes a tick-witness-extract loop:
6//!
7//!   JS: create → set embeddings → tick(audio) → read witness → extract sematon
8//!
9//! No wasm-bindgen dependency here — that lives in terminals-wasm.
10
11use super::atom::ComputeAtom;
12use super::coupling::{coupled_step, AudioMetrics};
13use super::expert::ExpertProjection;
14use super::fold::fold_to_json;
15use super::graph::{GraphProjection, PadicAddr};
16use super::kuramoto::KuramotoProjection;
17use super::layout::ProjectionLayout;
18use super::sematon::Sematon;
19use super::splat::SplatProjection;
20use super::witness::{compute_witness, ConvergenceWitness};
21use crate::primitives::vector;
22
23/// The substrate engine: N atoms, a layout, and physics parameters.
24/// Single-owner — WASM is single-threaded so this is safe as a JS-side object.
25pub struct SubstrateEngine {
26    atoms: Vec<ComputeAtom>,
27    layout: ProjectionLayout,
28    step: u32,
29    witness: ConvergenceWitness,
30    k_base: f32,
31    gravity_scale: f32,
32}
33
34impl SubstrateEngine {
35    /// Create a new substrate with `n` atoms.
36    /// `full_layout`: true = all 4 projections (1592 bytes/atom),
37    ///                false = minimal Splat+Kuramoto (1548 bytes/atom).
38    pub fn new(n: usize, full_layout: bool, k_base: f32, gravity_scale: f32) -> Self {
39        let layout = if full_layout {
40            ProjectionLayout::full()
41        } else {
42            ProjectionLayout::minimal()
43        };
44        let atoms = ComputeAtom::create_n(&layout, n);
45        Self {
46            atoms,
47            layout,
48            step: 0,
49            witness: ConvergenceWitness::default(),
50            k_base,
51            gravity_scale,
52        }
53    }
54
55    /// Run one physics step. Returns [R, entropy, converged (0.0/1.0), step].
56    pub fn tick(&mut self, bass: f32, mid: f32, high: f32, entropy: f32, dt: f32) -> [f32; 4] {
57        let audio = AudioMetrics {
58            bass,
59            mid,
60            high,
61            entropy,
62        };
63
64        coupled_step(
65            &mut self.atoms,
66            &audio,
67            self.k_base,
68            self.gravity_scale,
69            dt,
70        );
71
72        self.step += 1;
73        self.witness = compute_witness(&self.atoms, self.step);
74
75        [
76            self.witness.r,
77            self.witness.entropy,
78            if self.witness.converged { 1.0 } else { 0.0 },
79            self.step as f32,
80        ]
81    }
82
83    /// Read all Kuramoto phases as a flat f32 array.
84    pub fn phases(&self) -> Vec<f32> {
85        self.atoms
86            .iter()
87            .map(|a| {
88                a.read_projection::<KuramotoProjection>()
89                    .map(|k| k.theta)
90                    .unwrap_or(0.0)
91            })
92            .collect()
93    }
94
95    /// Current witness as [R, entropy, converged, step].
96    pub fn witness_array(&self) -> [f32; 4] {
97        [
98            self.witness.r,
99            self.witness.entropy,
100            if self.witness.converged { 1.0 } else { 0.0 },
101            self.step as f32,
102        ]
103    }
104
105    /// Set the 384-dim embedding for atom at `index`.
106    /// Returns false if index out of bounds or embedding wrong length.
107    pub fn set_embedding(&mut self, index: usize, embedding: &[f32]) -> bool {
108        if index >= self.atoms.len() || embedding.len() != 384 {
109            return false;
110        }
111        let mut splat = self.atoms[index]
112            .read_projection::<SplatProjection>()
113            .unwrap_or_default();
114        splat.embedding.copy_from_slice(embedding);
115        self.atoms[index].write_projection(&splat)
116    }
117
118    /// Set Kuramoto phase and natural frequency for atom at `index`.
119    pub fn set_phase(&mut self, index: usize, theta: f32, omega: f32) -> bool {
120        if index >= self.atoms.len() {
121            return false;
122        }
123        let mut kp = self.atoms[index]
124            .read_projection::<KuramotoProjection>()
125            .unwrap_or_default();
126        kp.theta = theta;
127        kp.omega = omega;
128        self.atoms[index].write_projection(&kp)
129    }
130
131    /// Set expert projection (intent routing) for atom at `index`.
132    pub fn set_expert(&mut self, index: usize, intent: f32, activation: f32, gate: f32) -> bool {
133        if index >= self.atoms.len() {
134            return false;
135        }
136        if !self.layout.has(super::projection::ProjectionId::Expert) {
137            return false;
138        }
139        let ep = ExpertProjection {
140            intent,
141            activation,
142            gate,
143        };
144        self.atoms[index].write_projection(&ep)
145    }
146
147    /// Set graph neighbor at a slot for atom at `index`.
148    pub fn set_neighbor(&mut self, index: usize, slot: usize, neighbor_index: u16) -> bool {
149        if index >= self.atoms.len() || slot >= super::graph::MAX_NEIGHBORS {
150            return false;
151        }
152        if !self.layout.has(super::projection::ProjectionId::Graph) {
153            return false;
154        }
155        let mut gp = self.atoms[index]
156            .read_projection::<GraphProjection>()
157            .unwrap_or_default();
158        gp.neighbors[slot] = neighbor_index;
159        self.atoms[index].write_projection(&gp)
160    }
161
162    /// Number of atoms.
163    pub fn atom_count(&self) -> usize {
164        self.atoms.len()
165    }
166
167    /// Current step number.
168    pub fn step_count(&self) -> u32 {
169        self.step
170    }
171
172    /// Stride in bytes per atom.
173    pub fn stride(&self) -> usize {
174        self.layout.stride
175    }
176
177    /// Whether the substrate has converged (R >= 0.9).
178    pub fn converged(&self) -> bool {
179        self.witness.converged
180    }
181
182    /// Current order parameter R.
183    pub fn order_parameter(&self) -> f32 {
184        self.witness.r
185    }
186
187    /// All embeddings as a flat f32 array (n * 384 elements).
188    pub fn embeddings_flat(&self) -> Vec<f32> {
189        let mut out = Vec::with_capacity(self.atoms.len() * 384);
190        for atom in &self.atoms {
191            if let Some(splat) = atom.read_projection::<SplatProjection>() {
192                out.extend_from_slice(&splat.embedding);
193            } else {
194                out.extend(std::iter::repeat(0.0f32).take(384));
195            }
196        }
197        out
198    }
199
200    /// Compute the centroid embedding across all atoms.
201    fn centroid_embedding(&self) -> Vec<f32> {
202        let mut centroid = vec![0.0f32; 384];
203        let mut count = 0usize;
204
205        for atom in &self.atoms {
206            if let Some(splat) = atom.read_projection::<SplatProjection>() {
207                for (i, &v) in splat.embedding.iter().enumerate() {
208                    centroid[i] += v;
209                }
210                count += 1;
211            }
212        }
213
214        if count == 0 {
215            return centroid;
216        }
217
218        let n = count as f32;
219        for v in &mut centroid {
220            *v /= n;
221        }
222
223        vector::normalize(&mut centroid);
224        centroid
225    }
226
227    /// Extract a sematon from the current substrate state.
228    /// Payload = centroid embedding. Returns JSON string of FoldedSematon.
229    pub fn extract_sematon(&self, source: &str) -> String {
230        let centroid = self.centroid_embedding();
231        let address = PadicAddr {
232            base: self.step,
233            coeff0: self.atoms.len() as u16,
234            coeff1: 0,
235        };
236        let sematon = Sematon::new(centroid, self.witness, address, source);
237        fold_to_json(&sematon)
238    }
239
240    /// Read the shape hash for atom at `index`.
241    pub fn atom_shape_hash(&self, index: usize) -> Option<u32> {
242        self.atoms.get(index).map(|a| a.shape_hash)
243    }
244
245    /// Update coupling parameters (k_base, gravity_scale).
246    pub fn set_coupling_params(&mut self, k_base: f32, gravity_scale: f32) {
247        self.k_base = k_base;
248        self.gravity_scale = gravity_scale;
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_engine_create_full() {
258        let engine = SubstrateEngine::new(10, true, 1.0, 1.0);
259        assert_eq!(engine.atom_count(), 10);
260        assert_eq!(engine.stride(), 1612);
261        assert_eq!(engine.step_count(), 0);
262        assert!(!engine.converged());
263    }
264
265    #[test]
266    fn test_engine_create_minimal() {
267        let engine = SubstrateEngine::new(5, false, 1.0, 1.0);
268        assert_eq!(engine.atom_count(), 5);
269        assert_eq!(engine.stride(), 1548);
270    }
271
272    #[test]
273    fn test_engine_set_and_read_embedding() {
274        let mut engine = SubstrateEngine::new(3, true, 1.0, 1.0);
275        let mut emb = vec![0.0f32; 384];
276        emb[0] = 1.0;
277
278        assert!(engine.set_embedding(0, &emb));
279        assert!(engine.set_embedding(1, &emb));
280        assert!(!engine.set_embedding(10, &emb)); // out of bounds
281        assert!(!engine.set_embedding(0, &[1.0; 10])); // wrong length
282
283        let flat = engine.embeddings_flat();
284        assert_eq!(flat.len(), 3 * 384);
285        assert!((flat[0] - 1.0).abs() < 1e-6);
286    }
287
288    #[test]
289    fn test_engine_set_phase() {
290        let mut engine = SubstrateEngine::new(3, false, 1.0, 1.0);
291        assert!(engine.set_phase(0, 1.5, 0.1));
292        assert!(engine.set_phase(2, 3.0, 0.2));
293        assert!(!engine.set_phase(5, 0.0, 0.0)); // out of bounds
294
295        let phases = engine.phases();
296        assert_eq!(phases.len(), 3);
297        assert!((phases[0] - 1.5).abs() < 1e-6);
298        assert!((phases[2] - 3.0).abs() < 1e-6);
299    }
300
301    #[test]
302    fn test_engine_set_expert() {
303        let mut engine = SubstrateEngine::new(2, true, 1.0, 1.0);
304        assert!(engine.set_expert(0, 0.5, 0.8, 1.0));
305
306        // Minimal layout doesn't have Expert
307        let mut engine_min = SubstrateEngine::new(2, false, 1.0, 1.0);
308        assert!(!engine_min.set_expert(0, 0.5, 0.8, 1.0));
309    }
310
311    #[test]
312    fn test_engine_tick_returns_witness() {
313        let mut engine = SubstrateEngine::new(6, false, 5.0, 1.0);
314
315        // Set same embedding direction for all atoms (high coupling)
316        let mut emb = vec![0.0f32; 384];
317        emb[0] = 1.0;
318        for i in 0..6 {
319            engine.set_embedding(i, &emb);
320            engine.set_phase(i, i as f32, 0.0);
321        }
322
323        // Tick once
324        let w = engine.tick(0.5, 0.3, 0.2, 0.5, 0.01);
325        assert_eq!(w.len(), 4);
326        assert!(w[0] >= 0.0 && w[0] <= 1.0); // R in [0,1]
327        assert_eq!(w[3], 1.0); // step = 1
328        assert_eq!(engine.step_count(), 1);
329    }
330
331    #[test]
332    fn test_engine_convergence_after_many_ticks() {
333        let mut engine = SubstrateEngine::new(6, false, 5.0, 1.0);
334
335        // Same embedding (high gravity coupling)
336        let mut emb = vec![0.0f32; 384];
337        emb[0] = 1.0;
338        for i in 0..6 {
339            engine.set_embedding(i, &emb);
340            engine.set_phase(i, i as f32, 0.0);
341        }
342
343        // Run many ticks
344        for _ in 0..1000 {
345            engine.tick(0.5, 0.3, 0.2, 0.5, 0.01);
346        }
347
348        assert!(
349            engine.order_parameter() > 0.9,
350            "R = {} (expected > 0.9)",
351            engine.order_parameter()
352        );
353        assert!(engine.converged());
354    }
355
356    #[test]
357    fn test_engine_extract_sematon() {
358        let mut engine = SubstrateEngine::new(4, false, 5.0, 1.0);
359
360        let mut emb = vec![0.0f32; 384];
361        emb[0] = 1.0;
362        for i in 0..4 {
363            engine.set_embedding(i, &emb);
364        }
365
366        // Tick to get a witness
367        engine.tick(0.5, 0.3, 0.2, 0.5, 0.01);
368
369        let json = engine.extract_sematon("test-surface");
370        assert!(!json.is_empty());
371        assert!(json.contains("test-surface"));
372        assert!(json.contains("witness_r"));
373    }
374
375    #[test]
376    fn test_engine_witness_array_matches_tick() {
377        let mut engine = SubstrateEngine::new(3, false, 1.0, 1.0);
378        let tick_w = engine.tick(0.0, 0.0, 0.0, 0.0, 0.01);
379        let read_w = engine.witness_array();
380
381        assert_eq!(tick_w[0], read_w[0]); // R
382        assert_eq!(tick_w[1], read_w[1]); // entropy
383        assert_eq!(tick_w[2], read_w[2]); // converged
384        assert_eq!(tick_w[3], read_w[3]); // step
385    }
386
387    #[test]
388    fn test_engine_set_coupling_params() {
389        let mut engine = SubstrateEngine::new(2, false, 1.0, 1.0);
390        engine.set_coupling_params(10.0, 2.0);
391        // Just verify no panic — the effect shows in tick behavior
392        engine.tick(0.5, 0.0, 0.0, 0.0, 0.01);
393    }
394
395    #[test]
396    fn test_engine_empty() {
397        let mut engine = SubstrateEngine::new(0, true, 1.0, 1.0);
398        assert_eq!(engine.atom_count(), 0);
399        let w = engine.tick(0.0, 0.0, 0.0, 0.0, 0.01);
400        assert_eq!(w[0], 0.0); // R = 0 for empty
401        assert!(engine.phases().is_empty());
402    }
403
404    #[test]
405    fn test_engine_atom_shape_hash() {
406        let mut engine = SubstrateEngine::new(2, false, 1.0, 1.0);
407        let h1 = engine.atom_shape_hash(0).unwrap();
408
409        engine.set_phase(0, 3.14, 1.0);
410        let h2 = engine.atom_shape_hash(0).unwrap();
411
412        assert_ne!(h1, h2); // Hash changed after write
413        assert!(engine.atom_shape_hash(99).is_none()); // Out of bounds
414    }
415}