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 };
});