scale-probe.js
// scale-probe.js -- does each system's EMERGENCE survive a change of SCALE (grid size / agent count / dt)?
// Not a unit test of correctness (the *-test.js files do that) but a robustness sweep: run each system's
// signature emergence at a SMALL scale and a LARGE scale (and, where dt matters, a fine and coarse dt) and
// confirm the qualitative bifurcation/threshold holds at BOTH. A system that only emerges at one hand-tuned
// grid is a fragile toy; a real model is scale-robust. Answers the operator's "sufficient depth AND scales?"
// by MEASURING it. node scale-probe.js.
const N = require('./natura.js');
const { World, UNITS } = N;
const { makeSync } = require('./sync.js');
const { makeTraffic } = require('./traffic.js');
const { makeStructure } = require('./structure.js');
const { makeWatershed } = require('./watershed.js');
const { makeStigmergy } = require('./stigmergy.js');
const { makeCaustics } = require('./caustics.js');
const { makeCrystal } = require('./crystal.js');
const { makeGranular } = require('./granular.js');
const { makeWeather } = require('./weather.js');
const { makeVegetation } = require('./vegetation.js');
const { makeFire } = require('./fire.js');
let pass = 0, fail = 0;
const P = (cond, msg) => { if (cond) { pass++; console.log(' ok ' + msg); } else { fail++; console.log(' FAIL ' + msg); } };
const stepN = (w, n, dt) => { for (let i = 0; i < n; i++) w.step(dt); return w; };
console.log('SCALE-ROBUSTNESS PROBE -- each emergence at small vs large scale\n');
// ---- SYNC: the Kuramoto transition. Supercritical K -> high R; subcritical -> low R. At BOTH population sizes.
console.log('sync (Kuramoto R): the sync transition is population-scale-invariant');
{
const R = (n, K) => stepN(World({ seed: 6, systems: [makeSync({ N: n, seed: 6, meanOmega: 6, freqSpread: 1.0, K })] }), 240, UNITS.HOUR).fields().sync.R;
const smallHi = R(50, 5), largeHi = R(400, 5), smallLo = R(50, 0.2), largeLo = R(400, 0.2);
P(smallHi > 0.8 && largeHi > 0.8, `supercritical locks at n=50 (R=${smallHi.toFixed(2)}) AND n=400 (R=${largeHi.toFixed(2)})`);
P(smallLo < 0.5 && largeLo < 0.5, `subcritical stays incoherent at n=50 (R=${smallLo.toFixed(2)}) AND n=400 (R=${largeLo.toFixed(2)})`);
}
// ---- TRAFFIC: the fundamental diagram. SAME density -> SAME regime regardless of absolute road length.
console.log('\ntraffic (fundamental diagram): a regime is set by DENSITY, not by absolute road length');
{
// free-flow density ~ 20 veh/km, jam density ~ 60 veh/km. carsFor(len, dens) = dens * len/1000.
const run = (len, cars) => { const f = stepN(World({ seed: 9, systems: [makeTraffic({ roadLen: len, N: cars, seed: 9 })] }), 8, UNITS.HOUR).fields().traffic; return f; };
const freeS = run(500, 10), freeL = run(1500, 30); // 20 veh/km both
const jamS = run(500, 35), jamL = run(1500, 105); // 70 veh/km both
P(freeS.jamCount === 0 && freeL.jamCount === 0, `free-flow at 20 veh/km on 500m (jam=${freeS.jamCount}) AND 1500m (jam=${freeL.jamCount})`);
P(jamS.jamCount > 0 && jamL.jamCount > 0, `phantom jams at 70 veh/km on 500m (jam=${jamS.jamCount}) AND 1500m (jam=${jamL.jamCount})`);
P(freeS.meanSpeed > jamS.meanSpeed * 1.5 && freeL.meanSpeed > jamL.meanSpeed * 1.5, `speed collapses jammed vs free at both lengths (small ${freeS.meanSpeed.toFixed(1)}>${jamS.meanSpeed.toFixed(1)}, large ${freeL.meanSpeed.toFixed(1)}>${jamL.meanSpeed.toFixed(1)})`);
}
// ---- STRUCTURE: the buckling pitchfork. maxDeflect is per-MEMBER (share = W/N vs Pcrit), so equal load-per-
// member gives the same bow whether the truss has few members or many. Both L-endpoints are always sampled, so
// the weakest Pcrit is identical across N -- a clean scale-invariance test of the pitchfork onset.
console.log('\nstructure (buckling pitchfork): the bow tracks load-PER-MEMBER, not member count');
{
// static load (loadRate 0) so the equilibrium is deterministic across scales; share = load/N.
const md = (n, load) => World({ seed: 1, systems: [makeStructure({ N: n, spread: 0.35, stiffness: 1, load, loadRate: 0 })] }).fields().structure.maxDeflect;
const small = md(8, 6), large = md(40, 30); // per-member share 0.75: bows the weakest (Pcrit~0.55) without failing it (<Pcrit*1.6)
P(small > 0 && large > 0 && Math.abs(small - large) < 0.15, `equal bow at N=8 (maxDeflect=${small.toFixed(3)}) AND N=40 (${large.toFixed(3)}) for equal per-member load`);
const stableS = md(8, 3.2), stableL = md(40, 16); // share 0.4 -- below the weakest Pcrit (~0.55), no bow at all
P(stableS === 0 && stableL === 0, `below critical, nothing bows at N=8 (${stableS}) AND N=40 (${stableL})`);
}
// ---- WATERSHED: drainage concentrates flow into rivers; springs emerge. At a small AND a large grid.
console.log('\nwatershed (drainage network): rivers concentrate + springs emerge at coarse AND fine grids');
{
const land = (x, y) => 0.5 + 0.35 * Math.sin(x * 3.1) * Math.cos(y * 2.7) - 0.25 * y; // a tilted rumpled plain
const run = (n) => { const w = World({ seed: 5, systems: [makeWatershed({ N: n, elevation: land, temperature: 12 })] }); stepN(w, 60, UNITS.DAY); return w.fields().watershed; };
const s = run(40), l = run(90);
// river = a few cells carry vastly more accumulated flow than the TYPICAL draining cell (true peak-to-mean)
const conc = (f) => { let sum = 0, c = 0; for (let k = 0; k < f.flowAccum.length; k++) if (f.flowAccum[k] > 0) { sum += f.flowAccum[k]; c++; } return c ? f.maxAcc / (sum / c) : 0; };
P(conc(s) > 5 && conc(l) > 5, `flow concentrates into rivers (peak/mean accum) at N=40 (${conc(s).toFixed(1)}x) AND N=90 (${conc(l).toFixed(1)}x)`);
P(s.riverCount > 0 && l.riverCount > 0, `a river network forms at N=40 (${s.riverCount} cells) AND N=90 (${l.riverCount} cells)`);
P(s.springCount > 0 && l.springCount > 0, `springs emerge at N=40 (${s.springCount}) AND N=90 (${l.springCount})`);
}
// ---- STIGMERGY: a concentrated pheromone trail forms between nest and food. At a small AND a large grid.
console.log('\nstigmergy (ant trail): a concentrated trail ridge forms at a small AND a large grid');
{
const run = (n, ants) => { const nest = { x: (n * 0.2) | 0, y: (n / 2) | 0 }; const food = [{ x: (n * 0.8) | 0, y: (n / 2) | 0, amount: Infinity }];
const w = World({ seed: 4, systems: [makeStigmergy({ N: n, seed: 4, ants, nest, food })] }); stepN(w, 320, UNITS.HOUR); return w.fields().stigmergy; };
const s = run(48, 150), l = run(96, 600);
P(s.trailConcentration > 3 && l.trailConcentration > 3, `trail is a concentrated ridge (peak-to-mean) at N=48 (${s.trailConcentration}) AND N=96 (${l.trailConcentration})`);
}
// ---- CAUSTICS: sunlight through a wavy surface gathers into bright caustics (peakToMean > 1). Coarse AND fine.
console.log('\ncaustics (light focusing): the floor light gathers into caustics at a coarse AND a fine grid');
{
const run = (n) => stepN(World({ seed: 8, systems: [makeCaustics({ N: n })] }), 30, UNITS.HOUR).fields().caustics.peakToMean;
const s = run(48), l = run(96);
P(s > 1.5 && l > 1.5, `light focuses (peak-to-mean > 1.5) at N=48 (${s.toFixed(2)}) AND N=96 (${l.toFixed(2)})`);
}
// ---- CRYSTAL: faster cooling -> MORE, smaller grains. The monotone must hold at a small AND a large grid.
console.log('\ncrystal (cooling-rate -> grain-size): faster cooling makes more/smaller grains at both grids');
{
const grains = (n, cool) => { const w = World({ seed: 2, systems: [makeCrystal({ N: n, seed: 2, coolRate: cool })] });
for (let i = 0; i < 4000; i++) { w.step(UNITS.HOUR); if (w.fields().crystal.solidFraction >= 1) break; } return w.fields().crystal.grainCount; };
const slowS = grains(40, 0.5), fastS = grains(40, 4);
const slowL = grains(80, 0.5), fastL = grains(80, 4);
P(fastS > slowS && fastL > slowL, `fast cooling -> more grains at N=40 (${slowS}->${fastS}) AND N=80 (${slowL}->${fastL})`);
}
// ---- GRANULAR: a poured pile holds an angle of repose (a peaked profile). Small AND large pile.
console.log('\ngranular (angle of repose): a poured heap holds a peaked profile at a small AND a large pile');
{
const heap = (n, h) => { const w = World({ seed: 3, systems: [makeGranular({ N: n, H: h, seed: 3, orifice: 0 })] }); stepN(w, 40, UNITS.HOUR); return w.fields().granular; };
const s = heap(31, 44), l = heap(61, 84);
// a heap: nonzero height, and it is wider than one cell (spread into a slope, not a vertical stack)
P(s.heapHeight > 0 && s.heapWidth > 3 && l.heapHeight > 0 && l.heapWidth > 3, `a spread heap forms at N=31 (h=${s.heapHeight},w=${s.heapWidth}) AND N=61 (h=${l.heapHeight},w=${l.heapWidth})`);
}
// ---- VEGETATION: inherits the rain-shadow (windward greener than lee). At a small AND a large grid.
console.log('\nvegetation (rain-shadow inheritance): windward greener than lee at a small AND a large grid');
{
const terr = (x) => x < -0.55 ? -0.2 : 0.15 + 0.6 * Math.exp(-(x * x) / 0.03);
const run = (g) => { const w = World({ seed: 3, systems: [makeWeather({ N: g, elevation: (x) => terr(x), seaLevel: 0 }), makeVegetation({ N: g, weather: 'weather' })] });
stepN(w, 200 * 6, UNITS.HOUR * 4); const v = w.get('vegetation');
const gx = (i) => (i / (g - 1)) * 2 - 1; let wS = 0, wN = 0, lS = 0, lN = 0;
for (let j = 0; j < g; j++) for (let i = 0; i < g; i++) { const k = j * g + i; if (!v.land[k]) continue; const x = gx(i);
if (x >= -0.45 && x <= -0.1) { wS += v.bio[k]; wN++; } if (x >= 0.1 && x <= 0.45) { lS += v.bio[k]; lN++; } }
return (wS / Math.max(1, wN)) / ((lS / Math.max(1, lN)) + 1e-6); };
const s = run(36), l = run(72);
P(s > 1.3 && l > 1.3, `windward/lee ratio holds at G=36 (${s.toFixed(2)}x) AND G=72 (${l.toFixed(2)}x)`);
}
// ---- SYNC dt-robustness: the locked R is stable under a fine vs a coarse integration step.
console.log('\nsync (dt-robustness): the locked state is the same under a fine and a coarse dt');
{
const R = (dt, steps) => stepN(World({ seed: 6, systems: [makeSync({ N: 200, seed: 6, meanOmega: 6, freqSpread: 1.0, K: 5 })] }), steps, dt).fields().sync.R;
const fine = R(UNITS.HOUR, 480), coarse = R(UNITS.HOUR * 3, 160); // same total time, 3x coarser
P(fine > 0.8 && coarse > 0.8 && Math.abs(fine - coarse) < 0.15, `R agrees fine vs coarse dt (fine=${fine.toFixed(2)}, coarse=${coarse.toFixed(2)})`);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);