← Natura·Docs/Modules/Source

niche.js

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

// niche.js -- Natura NICHE CONSTRUCTION (ONTOLOGY-FOLD-IN #2, "the deepest living-world move left"): life that
// REMAKES its world and then lives in the changed world -- the inverse of "weather changes the world it falls
// on." A population of BUILDERS (beavers, corals, worms, termites) writes a PERSISTENT structure into a field
// (a dam, a reef, a mound), and that structure RAISES the local habitat, which raises the builders' own carrying
// capacity -- so building lifts the population far above what the bare ground would hold (ecosystem engineering,
// a positive feedback). And the structure OUTLASTS its builders: kill the population and the dam still holds,
// the reef still stands, decaying only slowly -- the world keeps the mark of a life long after it is gone (the
// legacy). Field-based (builder density + built-structure + derived habitat), deterministic, node-testable.
// Couples out via its `habitat`/`built` fields (other life colonises the engineered ground). UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Niche = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;

  // config: { N, base (bare-ground capacity), buildGain (built -> habitat lift), buildRate, decayDays
  //   (how slowly structures crumble unmaintained), growth, mortality, disperse, builtMax, popMax, seed, maxDtDays }
  function makeNiche(config) {
    config = config || {};
    const N = config.N || 48;
    const base = config.base != null ? config.base : 0.12;         // capacity of BARE ground (builders barely hang on)
    const buildGain = config.buildGain != null ? config.buildGain : 0.9;  // how much a built structure raises local habitat
    const buildRate = config.buildRate != null ? config.buildRate : 0.12; // structure added per unit builder per day
    const decayDays = config.decayDays != null ? config.decayDays : 420;  // structure e-folding decay when unmaintained
    const growth = config.growth != null ? config.growth : 0.35;   // builder growth rate/day
    const mortality = config.mortality != null ? config.mortality : 0.05; // builder death rate/day
    const disperse = config.disperse != null ? config.disperse : 0.05;    // builder spread to neighbours
    const builtMax = config.builtMax != null ? config.builtMax : 1.5;
    const popMax = config.popMax != null ? config.popMax : 1.5;
    const seedPop = config.seed != null ? config.seed : 0.15;      // starting builders in a central patch (0 = none)
    const maxDd = config.maxDtDays != null ? config.maxDtDays : 2;
    const suitCfg = config.suitability != null ? config.suitability : 1;  // where the ground SUITS building (valleys/wet); 0..1 multiplier on habitat. default 1 = uniform
    const gx = (i) => (i / (N - 1)) * 2 - 1;

    return {
      name: config.name || 'niche',
      domain: 'life',
      scale: 'ecological',
      params: config,

      init() {
        const pop = new Float32Array(N * N), built = new Float32Array(N * N), suit = new Float32Array(N * N);
        for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) suit[j * N + i] = clamp(typeof suitCfg === 'function' ? suitCfg(gx(i), gx(j)) : suitCfg, 0, 1);
        if (seedPop > 0) { const c = (N / 2) | 0; for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) { const k = (c + dj) * N + (c + di); if (k >= 0 && k < N * N) pop[k] = seedPop; } }
        return { N, pop, built, suit, meanPop: 0, meanBuilt: 0, event: null };
      },

      step(st, ctx, dt) {
        const dd = clamp(dt / 86400, 0, maxDd); if (dd <= 0) return st;
        const { pop, built } = st;
        const decayF = Math.exp(-dd / decayDays);
        const np = new Float32Array(N * N);
        let est = 0;
        const suit = st.suit;
        for (let k = 0; k < N * N; k++) {
          const hab = (base + buildGain * built[k]) * suit[k];     // the structure MAKES the habitat, where the ground SUITS it (valleys, wet ground)
          const p = pop[k];
          np[k] = clamp(p + growth * p * (1 - p / Math.max(hab, 1e-3)) * dd - mortality * p * dd, 0, popMax); // logistic toward the engineered capacity
          const b = clamp(built[k] * decayF + buildRate * np[k] * dd, 0, builtMax);   // builders add structure; it crumbles slowly
          if (built[k] < 0.5 && b >= 0.5) est++;
          built[k] = b;
        }
        // the builders spread into neighbouring ground (discretized Laplacian on the pre-dispersal field)
        if (disperse > 0) {
          for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
            const k = j * N + i; let acc = 0, n = 0;
            if (i > 0) { acc += np[k - 1] - np[k]; n++; } if (i < N - 1) { acc += np[k + 1] - np[k]; n++; }
            if (j > 0) { acc += np[k - N] - np[k]; n++; } if (j < N - 1) { acc += np[k + N] - np[k]; n++; }
            pop[k] = n ? clamp(np[k] + disperse * dd * (acc / n), 0, popMax) : np[k];
          }
        } else { for (let k = 0; k < N * N; k++) pop[k] = np[k]; }
        let sp = 0, sb = 0; for (let k = 0; k < N * N; k++) { sp += pop[k]; sb += built[k]; }
        st.meanPop = sp / (N * N); st.meanBuilt = sb / (N * N); st.event = est > 0 ? 'engineered' : null;
        return st;
      },

      // OUT: builder density, the built structure, and the derived HABITAT (other life reads this to colonise the engineered ground)
      fields(st) {
        const hab = new Float32Array(st.N * st.N);
        for (let k = 0; k < hab.length; k++) hab[k] = base + buildGain * st.built[k];
        return { N: st.N, pop: st.pop, built: st.built, habitat: hab, suit: st.suit, meanPop: st.meanPop, meanBuilt: st.meanBuilt };
      },
      events(st) { return st.event ? [{ kind: st.event }] : []; }
    };
  }

  return { makeNiche };
});