terminals-core 0.1.0

Core runtime primitives for Terminals OS: phase dynamics, AXON wire protocol, substrate engine, and sematonic types
Documentation
//! Coupling — Gravity-weighted Kuramoto stepping for substrates.
//!
//! The coupling function K_ij = K_base × G × cos(sim(e_i, e_j)) makes
//! semantically similar atoms synchronize faster. This is the geometric
//! kernel: the coupling IS the kernel function on the semantic manifold.
//!
//! Uses the existing `phase::kuramoto::kuramoto_step` for the actual
//! phase evolution, but computes the adjacency matrix from embeddings.

use super::atom::ComputeAtom;
use super::kuramoto::KuramotoProjection;
use super::splat::SplatProjection;
use crate::phase::kuramoto::{kuramoto_step, Omega};
use crate::primitives::vector::cosine_similarity;

/// Audio metrics passed from JS via SharedArrayBuffer.
#[derive(Debug, Clone, Copy, Default)]
pub struct AudioMetrics {
    pub bass: f32,
    pub mid: f32,
    pub high: f32,
    pub entropy: f32,
}

/// Compute gravity coupling between two atoms using embedding similarity.
/// K_ij = K_base × gravity_scale × max(0, cos(sim(e_i, e_j)))
pub fn gravity_coupling(
    embedding_a: &[f32],
    embedding_b: &[f32],
    k_base: f32,
    gravity_scale: f32,
) -> f32 {
    let sim = cosine_similarity(embedding_a, embedding_b);
    k_base * gravity_scale * sim.max(0.0)
}

/// Perform one coupled Kuramoto step on all atoms in the substrate.
///
/// This is the core physics loop:
/// 1. Extract phases from Kuramoto projections
/// 2. Compute coupling from audio (CHORD protocol): K = K_base * (1 + bass * 2)
/// 3. Build adjacency matrix from embedding similarity (gravity coupling)
/// 4. Run kuramoto_step with weighted adjacency
/// 5. Write updated phases back to atoms
///
/// Returns the new phases (also written back into atoms).
pub fn coupled_step(
    atoms: &mut [ComputeAtom],
    audio: &AudioMetrics,
    k_base: f32,
    gravity_scale: f32,
    dt: f32,
) -> Vec<f32> {
    let n = atoms.len();
    if n == 0 {
        return vec![];
    }

    // CHORD protocol: bass drives coupling strength
    let k = k_base * (1.0 + audio.bass * 2.0);

    // Extract current phases and natural frequencies
    let mut phases = Vec::with_capacity(n);
    let mut omegas = Vec::with_capacity(n);
    for atom in atoms.iter() {
        if let Some(kp) = atom.read_projection::<KuramotoProjection>() {
            phases.push(kp.theta);
            omegas.push(kp.omega);
        } else {
            phases.push(0.0);
            omegas.push(0.0);
        }
    }

    // Build adjacency matrix from embedding gravity coupling.
    // For N <= 1000 this is O(N²) — acceptable per Approach A/C analysis.
    // At N=1000, this is 1M cosine_similarity calls of 384-dim vectors.
    // For larger N, HNSW would be needed (feature flag: +hnsw).
    let adjacency: Vec<Vec<f32>> = if atoms.iter().any(|a| a.layout.has(super::projection::ProjectionId::Splat)) {
        // Extract embeddings
        let embeddings: Vec<Option<SplatProjection>> = atoms
            .iter()
            .map(|a| a.read_projection::<SplatProjection>())
            .collect();

        (0..n)
            .map(|i| {
                (0..n)
                    .map(|j| {
                        if i == j {
                            return 0.0;
                        }
                        match (&embeddings[i], &embeddings[j]) {
                            (Some(ei), Some(ej)) => {
                                gravity_coupling(&ei.embedding, &ej.embedding, k, gravity_scale)
                            }
                            _ => k, // Fallback: uniform coupling if no embedding
                        }
                    })
                    .collect()
            })
            .collect()
    } else {
        // No splat projection — use uniform coupling (all-to-all)
        vec![vec![k; n]; n]
    };

    // Build adjacency as slices for kuramoto_step
    let adj_refs: Vec<&[f32]> = adjacency.iter().map(|row| row.as_slice()).collect();

    // Run the Kuramoto stepper (from phase::kuramoto)
    let new_phases = kuramoto_step(
        &phases,
        &Omega::PerNode(omegas),
        1.0, // K is already baked into adjacency weights
        Some(&adj_refs),
        dt,
        true, // wrap to (-π, π]
    );

    // Write updated phases back to atoms
    for (i, atom) in atoms.iter_mut().enumerate() {
        if let Some(mut kp) = atom.read_projection::<KuramotoProjection>() {
            kp.theta = new_phases[i];
            atom.write_projection(&kp);
        }
    }

    new_phases
}

#[cfg(test)]
mod tests {
    use super::*;
    use super::super::layout::ProjectionLayout;
    use crate::phase::kuramoto::order_parameter;

    #[test]
    fn test_gravity_coupling_identical() {
        let e = vec![1.0f32; 384];
        let c = gravity_coupling(&e, &e, 1.0, 1.0);
        assert!((c - 1.0).abs() < 1e-3, "coupling = {}", c);
    }

    #[test]
    fn test_gravity_coupling_orthogonal() {
        let mut a = vec![0.0f32; 384];
        let mut b = vec![0.0f32; 384];
        a[0] = 1.0;
        b[1] = 1.0;
        let c = gravity_coupling(&a, &b, 1.0, 1.0);
        assert!(c.abs() < 1e-3, "coupling = {}", c);
    }

    #[test]
    fn test_coupled_step_converges() {
        let layout = ProjectionLayout::minimal(); // Splat + Kuramoto
        let mut atoms = ComputeAtom::create_n(&layout, 6);

        // Give similar embeddings (high coupling) and spread phases
        for (i, atom) in atoms.iter_mut().enumerate() {
            let mut splat = SplatProjection::default();
            splat.embedding[0] = 1.0; // All point same direction → high similarity
            atom.write_projection(&splat);

            let k = KuramotoProjection {
                theta: i as f32,
                omega: 0.0,
                coupling: 1.0,
            };
            atom.write_projection(&k);
        }

        let phases_before: Vec<f32> = atoms
            .iter()
            .filter_map(|a| a.read_projection::<KuramotoProjection>())
            .map(|k| k.theta)
            .collect();
        let r_before = order_parameter(&phases_before).r;

        let audio = AudioMetrics { bass: 0.5, mid: 0.3, high: 0.2, entropy: 0.5 };

        // Run many steps — gravity coupling converges slower than uniform
        for _ in 0..1000 {
            coupled_step(&mut atoms, &audio, 5.0, 1.0, 0.01);
        }

        let phases_after: Vec<f32> = atoms
            .iter()
            .filter_map(|a| a.read_projection::<KuramotoProjection>())
            .map(|k| k.theta)
            .collect();
        let r_after = order_parameter(&phases_after).r;

        assert!(
            r_after > r_before,
            "R should increase: before={} after={}",
            r_before,
            r_after
        );
        assert!(r_after > 0.9, "R should converge: R={}", r_after);
    }

    #[test]
    fn test_coupled_step_empty() {
        let result = coupled_step(&mut [], &AudioMetrics::default(), 1.0, 1.0, 0.01);
        assert!(result.is_empty());
    }

    #[test]
    fn test_coupled_step_preserves_atom_count() {
        let layout = ProjectionLayout::minimal();
        let mut atoms = ComputeAtom::create_n(&layout, 10);
        let result = coupled_step(&mut atoms, &AudioMetrics::default(), 1.0, 1.0, 0.01);
        assert_eq!(result.len(), 10);
    }
}