Skip to main content

terminals_core/substrate/
coupling.rs

1//! Coupling — Gravity-weighted Kuramoto stepping for substrates.
2//!
3//! The coupling function K_ij = K_base × G × cos(sim(e_i, e_j)) makes
4//! semantically similar atoms synchronize faster. This is the geometric
5//! kernel: the coupling IS the kernel function on the semantic manifold.
6//!
7//! Uses the existing `phase::kuramoto::kuramoto_step` for the actual
8//! phase evolution, but computes the adjacency matrix from embeddings.
9
10use super::atom::ComputeAtom;
11use super::kuramoto::KuramotoProjection;
12use super::splat::SplatProjection;
13use crate::phase::kuramoto::{kuramoto_step, Omega};
14use crate::primitives::vector::cosine_similarity;
15
16/// Audio metrics passed from JS via SharedArrayBuffer.
17#[derive(Debug, Clone, Copy, Default)]
18pub struct AudioMetrics {
19    pub bass: f32,
20    pub mid: f32,
21    pub high: f32,
22    pub entropy: f32,
23}
24
25/// Compute gravity coupling between two atoms using embedding similarity.
26/// K_ij = K_base × gravity_scale × max(0, cos(sim(e_i, e_j)))
27pub fn gravity_coupling(
28    embedding_a: &[f32],
29    embedding_b: &[f32],
30    k_base: f32,
31    gravity_scale: f32,
32) -> f32 {
33    let sim = cosine_similarity(embedding_a, embedding_b);
34    k_base * gravity_scale * sim.max(0.0)
35}
36
37/// Perform one coupled Kuramoto step on all atoms in the substrate.
38///
39/// This is the core physics loop:
40/// 1. Extract phases from Kuramoto projections
41/// 2. Compute coupling from audio (CHORD protocol): K = K_base * (1 + bass * 2)
42/// 3. Build adjacency matrix from embedding similarity (gravity coupling)
43/// 4. Run kuramoto_step with weighted adjacency
44/// 5. Write updated phases back to atoms
45///
46/// Returns the new phases (also written back into atoms).
47pub fn coupled_step(
48    atoms: &mut [ComputeAtom],
49    audio: &AudioMetrics,
50    k_base: f32,
51    gravity_scale: f32,
52    dt: f32,
53) -> Vec<f32> {
54    let n = atoms.len();
55    if n == 0 {
56        return vec![];
57    }
58
59    // CHORD protocol: bass drives coupling strength
60    let k = k_base * (1.0 + audio.bass * 2.0);
61
62    // Extract current phases and natural frequencies
63    let mut phases = Vec::with_capacity(n);
64    let mut omegas = Vec::with_capacity(n);
65    for atom in atoms.iter() {
66        if let Some(kp) = atom.read_projection::<KuramotoProjection>() {
67            phases.push(kp.theta);
68            omegas.push(kp.omega);
69        } else {
70            phases.push(0.0);
71            omegas.push(0.0);
72        }
73    }
74
75    // Build adjacency matrix from embedding gravity coupling.
76    // For N <= 1000 this is O(N²) — acceptable per Approach A/C analysis.
77    // At N=1000, this is 1M cosine_similarity calls of 384-dim vectors.
78    // For larger N, HNSW would be needed (feature flag: +hnsw).
79    let adjacency: Vec<Vec<f32>> = if atoms.iter().any(|a| a.layout.has(super::projection::ProjectionId::Splat)) {
80        // Extract embeddings
81        let embeddings: Vec<Option<SplatProjection>> = atoms
82            .iter()
83            .map(|a| a.read_projection::<SplatProjection>())
84            .collect();
85
86        (0..n)
87            .map(|i| {
88                (0..n)
89                    .map(|j| {
90                        if i == j {
91                            return 0.0;
92                        }
93                        match (&embeddings[i], &embeddings[j]) {
94                            (Some(ei), Some(ej)) => {
95                                gravity_coupling(&ei.embedding, &ej.embedding, k, gravity_scale)
96                            }
97                            _ => k, // Fallback: uniform coupling if no embedding
98                        }
99                    })
100                    .collect()
101            })
102            .collect()
103    } else {
104        // No splat projection — use uniform coupling (all-to-all)
105        vec![vec![k; n]; n]
106    };
107
108    // Build adjacency as slices for kuramoto_step
109    let adj_refs: Vec<&[f32]> = adjacency.iter().map(|row| row.as_slice()).collect();
110
111    // Run the Kuramoto stepper (from phase::kuramoto)
112    let new_phases = kuramoto_step(
113        &phases,
114        &Omega::PerNode(omegas),
115        1.0, // K is already baked into adjacency weights
116        Some(&adj_refs),
117        dt,
118        true, // wrap to (-π, π]
119    );
120
121    // Write updated phases back to atoms
122    for (i, atom) in atoms.iter_mut().enumerate() {
123        if let Some(mut kp) = atom.read_projection::<KuramotoProjection>() {
124            kp.theta = new_phases[i];
125            atom.write_projection(&kp);
126        }
127    }
128
129    new_phases
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use super::super::layout::ProjectionLayout;
136    use crate::phase::kuramoto::order_parameter;
137
138    #[test]
139    fn test_gravity_coupling_identical() {
140        let e = vec![1.0f32; 384];
141        let c = gravity_coupling(&e, &e, 1.0, 1.0);
142        assert!((c - 1.0).abs() < 1e-3, "coupling = {}", c);
143    }
144
145    #[test]
146    fn test_gravity_coupling_orthogonal() {
147        let mut a = vec![0.0f32; 384];
148        let mut b = vec![0.0f32; 384];
149        a[0] = 1.0;
150        b[1] = 1.0;
151        let c = gravity_coupling(&a, &b, 1.0, 1.0);
152        assert!(c.abs() < 1e-3, "coupling = {}", c);
153    }
154
155    #[test]
156    fn test_coupled_step_converges() {
157        let layout = ProjectionLayout::minimal(); // Splat + Kuramoto
158        let mut atoms = ComputeAtom::create_n(&layout, 6);
159
160        // Give similar embeddings (high coupling) and spread phases
161        for (i, atom) in atoms.iter_mut().enumerate() {
162            let mut splat = SplatProjection::default();
163            splat.embedding[0] = 1.0; // All point same direction → high similarity
164            atom.write_projection(&splat);
165
166            let k = KuramotoProjection {
167                theta: i as f32,
168                omega: 0.0,
169                coupling: 1.0,
170            };
171            atom.write_projection(&k);
172        }
173
174        let phases_before: Vec<f32> = atoms
175            .iter()
176            .filter_map(|a| a.read_projection::<KuramotoProjection>())
177            .map(|k| k.theta)
178            .collect();
179        let r_before = order_parameter(&phases_before).r;
180
181        let audio = AudioMetrics { bass: 0.5, mid: 0.3, high: 0.2, entropy: 0.5 };
182
183        // Run many steps — gravity coupling converges slower than uniform
184        for _ in 0..1000 {
185            coupled_step(&mut atoms, &audio, 5.0, 1.0, 0.01);
186        }
187
188        let phases_after: Vec<f32> = atoms
189            .iter()
190            .filter_map(|a| a.read_projection::<KuramotoProjection>())
191            .map(|k| k.theta)
192            .collect();
193        let r_after = order_parameter(&phases_after).r;
194
195        assert!(
196            r_after > r_before,
197            "R should increase: before={} after={}",
198            r_before,
199            r_after
200        );
201        assert!(r_after > 0.9, "R should converge: R={}", r_after);
202    }
203
204    #[test]
205    fn test_coupled_step_empty() {
206        let result = coupled_step(&mut [], &AudioMetrics::default(), 1.0, 1.0, 0.01);
207        assert!(result.is_empty());
208    }
209
210    #[test]
211    fn test_coupled_step_preserves_atom_count() {
212        let layout = ProjectionLayout::minimal();
213        let mut atoms = ComputeAtom::create_n(&layout, 10);
214        let result = coupled_step(&mut atoms, &AudioMetrics::default(), 1.0, 1.0, 0.01);
215        assert_eq!(result.len(), 10);
216    }
217}