← Natura·Docs/Modules/Source

sync.js

85 lines · in natura-src-1.0.0.zip as natura/sync.js · a module · its entry in the module reference · its test: sync-test.js

// sync.js -- Natura SYNCHRONIZATION (the Kuramoto model): a rhythm no one called. A population of phase
// oscillators, each with its OWN natural tempo, each nudged toward the phases it can hear -- and as the coupling
// K crosses a critical threshold the crowd spontaneously LOCKS into one beat (or, with a phase-lag on a ring,
// splits into a CHIMERA: a synchronized domain and an incoherent one, coexisting). Fireflies flashing as one,
// a clapping crowd finding a rhythm, metronomes on a shared board, crickets, pacemaker cells. The emergence is
// the order parameter R climbing from ~0 (noise) to ~1 (one beat) through Kc. Reusable core of the August
// synchrony pieces (The Beat Nobody Called, Some Keep Time, ...). Deterministic, node-testable. UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Sync = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';
  const TAU = Math.PI * 2, clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }

  // config: { N, seed, K, mode('global'|'ring'), neighbours, meanOmega, freqSpread, phaseLag, dtInner, maxSub, name }
  function makeSync(config) {
    config = config || {};
    const N = config.N != null ? config.N : 200;
    const mode = config.mode || 'global';
    const NB = config.neighbours != null ? config.neighbours : 12;     // ring: how many nearest each hears
    const meanOmega = config.meanOmega != null ? config.meanOmega : 6.0;  // rad/day
    const freqSpread = config.freqSpread != null ? config.freqSpread : 1.0;
    const lag = config.phaseLag != null ? config.phaseLag : 0;         // Sakaguchi alpha (near pi/2 -> chimeras on a ring)
    const dtInner = config.dtInner != null ? config.dtInner : 0.05;    // integration step (days)
    const maxSub = config.maxSub != null ? config.maxSub : 400;
    function gauss(rng) { let u = 0, v = 0; while (u === 0) u = rng(); while (v === 0) v = rng(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(TAU * v); }

    return {
      name: config.name || 'sync',
      domain: 'physical',
      scale: 'ecological',
      params: config,

      init() {
        const rng = mulberry32(config.seed || 1), phase = new Float64Array(N), omega = new Float64Array(N);
        for (let i = 0; i < N; i++) { phase[i] = rng() * TAU; omega[i] = meanOmega + freqSpread * gauss(rng); }
        return { N, phase, omega, K: config.K != null ? config.K : 2.0, _rng: rng };
      },

      step(st, ctx, dt) {
        const steps = clamp(Math.round((dt / 86400) / dtInner), 1, maxSub), { phase, omega } = st, K = st.K;
        for (let s = 0; s < steps; s++) {
          if (mode === 'global') {
            let sx = 0, sy = 0; for (let i = 0; i < N; i++) { sx += Math.cos(phase[i]); sy += Math.sin(phase[i]); }
            sx /= N; sy /= N; const R = Math.hypot(sx, sy), psi = Math.atan2(sy, sx);   // mean-field pull
            for (let i = 0; i < N; i++) phase[i] += (omega[i] + K * R * Math.sin(psi - phase[i] - lag)) * dtInner;
          } else {                                          // RING: each hears only its NB nearest neighbours
            const np = st._np || (st._np = new Float64Array(N)), half = NB >> 1;
            for (let i = 0; i < N; i++) { let acc = 0; for (let d = 1; d <= half; d++) { acc += Math.sin(phase[(i + d) % N] - phase[i] - lag); acc += Math.sin(phase[(i - d + N) % N] - phase[i] - lag); }
              np[i] = phase[i] + (omega[i] + (K / NB) * acc) * dtInner; }
            for (let i = 0; i < N; i++) phase[i] = np[i];
          }
        }
        for (let i = 0; i < N; i++) { phase[i] %= TAU; if (phase[i] < 0) phase[i] += TAU; }
        return st;
      },

      // OUT: R (global coherence 0..1 -- the READOUT of synchronization), meanPhase, localR (per-oscillator
      // coherence with its ring neighbours), lockedFraction; events locked/incoherent/chimera. Other systems read
      // `phase`/`R` (a chorus that flashes/calls as one is a behaviour verb) or drive K from a coupling scenario.
      fields(st) {
        const N = st.N, phase = st.phase;
        let sx = 0, sy = 0; for (let i = 0; i < N; i++) { sx += Math.cos(phase[i]); sy += Math.sin(phase[i]); }
        sx /= N; sy /= N; const R = Math.hypot(sx, sy), psi = Math.atan2(sy, sx);
        const localR = st._lr || (st._lr = new Float64Array(N)), half = Math.max(1, (NB >> 1));
        let lo = 1, hi = 0, locked = 0;
        for (let i = 0; i < N; i++) { let lx = Math.cos(phase[i]), ly = Math.sin(phase[i]), n = 1;
          for (let d = 1; d <= half; d++) { lx += Math.cos(phase[(i + d) % N]) + Math.cos(phase[(i - d + N) % N]); ly += Math.sin(phase[(i + d) % N]) + Math.sin(phase[(i - d + N) % N]); n += 2; }
          const lr = Math.hypot(lx / n, ly / n); localR[i] = lr; if (lr < lo) lo = lr; if (lr > hi) hi = lr; if (lr > 0.9) locked++; }
        return { N, phase, naturalFreq: st.omega, K: st.K, R: +R.toFixed(4), meanPhase: +psi.toFixed(4),
          localR, localRmin: +lo.toFixed(3), localRmax: +hi.toFixed(3), lockedFraction: +(locked / N).toFixed(3) };
      },

      events(st) {
        const f = this.fields(st), out = [];
        if (f.R > 0.9) out.push({ type: 'locked', R: f.R });
        else if (f.R < 0.2) out.push({ type: 'incoherent', R: f.R });
        if (mode === 'ring' && f.localRmax > 0.85 && f.localRmin < 0.4 && f.R < 0.85) out.push({ type: 'chimera', R: f.R });
        return out;
      }
    };
  }
  return { makeSync };
});