← Natura·Docs/Modules/Source

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