stigmergy.js
118 lines · in natura-src-1.0.0.zip as natura/stigmergy.js · a module · its entry in the module reference · its test: stigmergy-test.js
// stigmergy.js -- Natura SELF-ORGANIZED TRAILS (ONTOLOGY: the INFORMATION primitive as a deposited-and-decaying
// spatial FIELD). A foraging highway that emerges with NO central plan: a colony's ants lay a HOME scent as they
// search and, once laden, a FOOD scent on the way back; searchers climb the FOOD gradient toward food while laden
// ants climb the HOME gradient back to the nest; unused scent EVAPORATES. Out of thousands of purely-local moves a
// shortest-ish path self-organizes -- and it FADES when the food runs out. This is the reusable core of the
// gallery piece "The Crumb Wars" (multi-colony war), folded down to the one-colony mechanism. Every deposited/
// decaying field -- scent trails, niche structures, fire scars -- is this same shape. Deterministic, node-testable. UMD.
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Stigmergy = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const TAU = Math.PI * 2;
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, nest{x,y}(cells), food:[{x,y,amount}](cells), ants, deposit, evaporate, diffuse,
// senseDist, senseAngle, turn, wander, speed, pickR, dropR, capScent, tickSec, maxDtDays, name }
function makeStigmergy(config) {
config = config || {};
const N = config.N || 48;
const nAnts = config.ants != null ? config.ants : 120;
const deposit = config.deposit != null ? config.deposit : 0.45;
const evap = config.evaporate != null ? config.evaporate : 0.99; // per tick
const diffuse = config.diffuse != null ? config.diffuse : 0.05;
const senseDist = config.senseDist != null ? config.senseDist : 3.2;
const senseAngle = config.senseAngle != null ? config.senseAngle : 0.5;
const turn = config.turn != null ? config.turn : 0.55;
const wander = config.wander != null ? config.wander : 0.35;
const speed = config.speed != null ? config.speed : 0.9;
const pickR = config.pickR != null ? config.pickR : 2.2;
const dropR = config.dropR != null ? config.dropR : 2.6;
const cap = config.capScent != null ? config.capScent : 8;
const tickSec = config.tickSec != null ? config.tickSec : 3600; // one ant-update = an hour
const maxSub = config.maxSub != null ? config.maxSub : 64;
const nest = config.nest || { x: N * 0.2, y: N * 0.5 };
const foodCfg = config.food || [{ x: N * 0.8, y: N * 0.5, amount: Infinity }];
const idx = (cx, cy) => cy * N + cx;
function sampleAhead(f, x, y, ang, d) { const cx = (x + Math.cos(ang) * d) | 0, cy = (y + Math.sin(ang) * d) | 0; if (cx < 0 || cy < 0 || cx >= N || cy >= N) return -1; return f[idx(cx, cy)]; }
return {
name: config.name || 'stigmergy',
domain: 'life',
scale: 'ecological',
params: config,
init() {
const M = N * N, home = new Float32Array(M), food = new Float32Array(M), rng = mulberry32(config.seed || 1);
const ants = new Array(nAnts);
for (let i = 0; i < nAnts; i++) ants[i] = { x: nest.x, y: nest.y, ang: rng() * TAU, laden: false };
const src = foodCfg.map(f => ({ x: f.x, y: f.y, amount: f.amount == null ? Infinity : f.amount }));
return { N, home, food, ants, src, nest: { x: nest.x, y: nest.y }, collected: 0, _rng: rng, _d: new Float32Array(M) };
},
step(st, ctx, dt) {
const subs = clamp(Math.round((dt || tickSec) / tickSec), 1, maxSub);
const { home, food, ants, src, nest, _rng } = st, M = N * N;
for (let s = 0; s < subs; s++) {
for (let ai = 0; ai < ants.length; ai++) {
const a = ants[ai];
if (a.laden) { // HOMING: path-integrate back to the nest (laying the FOOD trail as it goes)
let de = Math.atan2(nest.y - a.y, nest.x - a.x) - a.ang; while (de > Math.PI) de -= 2 * Math.PI; while (de < -Math.PI) de += 2 * Math.PI;
a.ang += clamp(de, -turn, turn) * 0.7 + (_rng() - 0.5) * wander * 0.5;
} else { // SEARCHING: climb the FOOD gradient, wander to explore
const c = sampleAhead(food, a.x, a.y, a.ang, senseDist);
const l = sampleAhead(food, a.x, a.y, a.ang + senseAngle, senseDist);
const r = sampleAhead(food, a.x, a.y, a.ang - senseAngle, senseDist);
if (l > c && l >= r) a.ang += turn; else if (r > c && r > l) a.ang -= turn;
a.ang += (_rng() - 0.5) * wander;
}
const drop = a.laden ? food : home; // laden lays the FOOD trail; searcher lays the HOME trail
a.x += Math.cos(a.ang) * speed; a.y += Math.sin(a.ang) * speed;
if (a.x < 0.5) { a.x = 0.5; a.ang = Math.PI - a.ang; } else if (a.x > N - 1.5) { a.x = N - 1.5; a.ang = Math.PI - a.ang; }
if (a.y < 0.5) { a.y = 0.5; a.ang = -a.ang; } else if (a.y > N - 1.5) { a.y = N - 1.5; a.ang = -a.ang; }
const k = idx(a.x | 0, a.y | 0); drop[k] = Math.min(cap, drop[k] + deposit); // mark the path just travelled
if (!a.laden) { for (const f of src) { if (f.amount > 0 && Math.hypot(a.x - f.x, a.y - f.y) < pickR) { a.laden = true; if (f.amount !== Infinity) f.amount--; a.ang += Math.PI; break; } } }
else if (Math.hypot(a.x - nest.x, a.y - nest.y) < dropR) { a.laden = false; st.collected++; a.ang += Math.PI; }
}
// EVAPORATE (the whole point: unfollowed trails fade) + a little lateral DIFFUSION
if (diffuse > 0) { const d = st._d; for (const fld of [home, food]) { d.fill(0); const D = Math.min(0.2, diffuse);
for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) { const q = y * N + x;
if (x < N - 1) { const fl = D * (fld[q] - fld[q + 1]); d[q] -= fl; d[q + 1] += fl; }
if (y < N - 1) { const fl = D * (fld[q] - fld[q + N]); d[q] -= fl; d[q + N] += fl; } }
for (let q = 0; q < M; q++) fld[q] = (fld[q] + d[q]) * evap; } }
else { for (let q = 0; q < M; q++) { home[q] *= evap; food[q] *= evap; } }
}
return st;
},
// OUT: the two trail fields + the ants + food/collected, and a `trailStrength` = mean FOOD scent along the
// nest->nearest-food corridor (rises as the highway self-organizes). Other systems read `foodTrail` as a
// ground scent cue (sensing), or the ants as agents (ecosystem).
fields(st) {
const src = st.src, nest = st.nest;
let foodRemaining = 0; for (const f of src) foodRemaining += (f.amount === Infinity ? 0 : f.amount);
// corridor sample: along the line nest -> nearest food source
let nf = src[0], best = Infinity; for (const f of src) { const d = Math.hypot(f.x - nest.x, f.y - nest.y); if (d < best) { best = d; nf = f; } }
let corr = 0, all = 0, mx = 0; const S = 26;
for (let k = 1; k < S; k++) { const u = k / S, x = (nest.x + (nf.x - nest.x) * u) | 0, y = (nest.y + (nf.y - nest.y) * u) | 0; if (x >= 0 && y >= 0 && x < st.N && y < st.N) corr += st.food[y * st.N + x]; }
corr /= (S - 1);
for (let q = 0; q < st.N * st.N; q++) { const v = st.food[q]; all += v; if (v > mx) mx = v; } all /= (st.N * st.N);
// near-food scent: does the trail actually REACH the food? (a disc around the source)
let atFood = 0, af = 0; for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { const x = (nf.x | 0) + dx, y = (nf.y | 0) + dy; if (x >= 0 && y >= 0 && x < st.N && y < st.N) { atFood += st.food[y * st.N + x]; af++; } } atFood /= (af || 1);
return { N: st.N, homeTrail: st.home, foodTrail: st.food, ants: st.ants, collected: st.collected,
foodRemaining, trailStrength: +corr.toFixed(4), trailMean: +all.toFixed(4), atFood: +atFood.toFixed(4),
trailConcentration: all > 1e-6 ? +(mx / all).toFixed(2) : 0 }; // peak-to-mean: a real trail has a high, concentrated ridge
},
events(st) {
const out = [];
let rem = 0, fin = false; for (const f of st.src) { if (f.amount !== Infinity) { fin = true; rem += f.amount; } }
if (fin && rem <= 0) out.push({ type: 'foodExhausted' });
return out;
}
};
}
return { makeStigmergy };
});