← Natura·Docs/Modules/Source

mutualism.js

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

// mutualism.js -- Natura MUTUALISM / COEVOLUTION (the cooperative +/+ edge of LIFE<->LIFE, the mirror of the
// ecosystem's antagonism). Every prior life coupling is a MINUS for someone: predation eats, disease kills,
// grazing/competition takes. Nowhere did two species HELP each other and both gain. Here a REWARDER guild
// (plants: they make nectar, a costly gift) and a SERVICE guild (pollinators: they carry pollen, a service the
// plant cannot do itself) each depend on the other to reproduce -- so their fates are LINKED, they rise and
// fall together, and neither can be understood alone. The naming is the flower/pollinator instance, but the
// model is the general reward-for-service mutualism: fig & wasp, coral & its algae (sugar for shelter), acacia
// & ant (nectar for defense), cleaner fish & client. Four coupled phenomena, each a real emergence proven by
// RUNNING (mutualism-test.js), never by reasoning:
//   1. RECIPROCAL COUPLING -- the two populations move TOGETHER (positively), the opposite of predator-prey's
//      out-of-phase chase; remove one and the other declines.
//   2. THE ALLEE THRESHOLD -- an OBLIGATE pair (neither reproduces without the other) is BISTABLE: a founder
//      pair too small collapses to extinction, above a critical density it climbs to a high mutual equilibrium.
//      Cooperation has to reach a quorum to catch. (mutualism as an Allee effect / a cooperation tipping point.)
//   3. COEVOLUTION -- each guild's TRAIT (corolla depth / tongue length) climbs a selection gradient toward its
//      partner's, so mismatched traits CONVERGE (Darwin's Madagascar orchid predicting its long-tongued moth),
//      and a community of several plants + pollinators SORTS into matched pairs (pollination syndromes).
//   4. THE CHEATER & SANCTIONS -- a nectar-robber takes the reward without doing the service. It saves the
//      cost of cooperating, so WITHOUT a check it INVADES and the mutualism collapses (the tragedy of the
//      commons). A plant that WITHHOLDS reward from ineffective visitors (sanctions / partner choice) makes
//      cheating unprofitable and holds cooperation stable. The reason mutualism is an evolutionary puzzle, and
//      one way nature answers it.
// Small single-concern file, factory makeMutualism(config), deterministic (no Date.now/Math.random), UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Mutualism = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  const EPS = 1e-6;

  // Build a guild's species list from config: either an explicit array of {trait,pop[,cheat]}, or a count
  // `n` whose traits are spread evenly across [lo,hi] and populations set to a default. Keeps init clean and
  // lets a test/piece say "2 plants at traits 0.3 and 0.7" or just "n:1".
  function buildGuild(spec, defPop, lo, hi) {
    if (Array.isArray(spec)) return spec.map(s => ({ trait: s.trait, pop: s.pop != null ? s.pop : defPop, cheat: s.cheat != null ? s.cheat : 0 }));
    const n = (spec && spec.n) || 1;
    const p0 = (spec && spec.pop != null) ? spec.pop : defPop;
    const out = [];
    for (let i = 0; i < n; i++) {
      const t = n === 1 ? (lo + hi) / 2 : lo + (hi - lo) * i / (n - 1);
      out.push({ trait: t, pop: p0, cheat: (spec && spec.cheat != null) ? spec.cheat : 0 });
    }
    return out;
  }

  function makeMutualism(config) {
    config = config || {};
    // --- interaction ---
    const sigma = config.matchWidth != null ? config.matchWidth : 0.22;   // trait-match tolerance (corolla vs tongue)
    const visitRate = config.visitRate != null ? config.visitRate : 1.0;  // base encounter coefficient
    const handling = config.handling != null ? config.handling : 0.7;     // Type-II saturation (a visitor handles only so many)
    const nectar = config.nectar != null ? config.nectar : 1.0;           // reward per visit
    const seedGain = config.seedGain != null ? config.seedGain : 1.3;     // plant seed-set per legit pollination
    const rewardConv = config.rewardConv != null ? config.rewardConv : 1.0; // visitor births per unit reward
    // --- demography ---
    const plantK = config.plantK != null ? config.plantK : 1.0;
    const pollK = config.pollK != null ? config.pollK : 1.0;
    const plantMort = config.plantMort != null ? config.plantMort : 0.14;
    const pollMort = config.pollMort != null ? config.pollMort : 0.14;
    const plantBaseline = config.plantBaseline != null ? config.plantBaseline : 0; // facultative self/wind seed (0 = OBLIGATE)
    const pollBaseline = config.pollBaseline != null ? config.pollBaseline : 0;    // facultative alt food  (0 = OBLIGATE)
    // --- cheating / sanctions ---
    const costService = config.costService != null ? config.costService : 0.18; // fitness cost of being a faithful pollinator
    const sanctions = config.sanctions != null ? config.sanctions : 0;   // 0..1: reward WITHHELD from non-pollinating visitors
    const cheatRate = config.cheatRate != null ? config.cheatRate : 0.6; // replicator speed on cheat frequency
    const cheatEvolve = config.cheatEvolve != null ? config.cheatEvolve : true;
    // --- coevolution ---
    const evolve = config.evolve != null ? config.evolve : false;
    const h2 = config.heritability != null ? config.heritability : 0.4;   // narrow-sense heritability
    const traitVar = config.traitVar != null ? config.traitVar : 0.03;    // additive genetic variance (evolvability)
    const traitCost = config.traitCost != null ? config.traitCost : 0;    // stabilizing pull to a physiological optimum
    const traitOptP = config.traitOptP != null ? config.traitOptP : 0.5;
    const traitOptA = config.traitOptA != null ? config.traitOptA : 0.5;
    const traitLo = config.traitLo != null ? config.traitLo : 0;
    const traitHi = config.traitHi != null ? config.traitHi : 1;
    const maxDd = config.maxDtDays != null ? config.maxDtDays : 2;

    const plantSpec = config.plants != null ? config.plants : { n: 1, pop: 0.5 };
    const pollSpec = config.pollinators != null ? config.pollinators : { n: 1, pop: 0.5 };

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

      init() {
        const plants = buildGuild(plantSpec, 0.5, 0.3, 0.7);
        const polls = buildGuild(pollSpec, 0.5, 0.3, 0.7);
        return {
          plants, polls,
          // observables filled by step()
          plantPop: 0, pollPop: 0, meanMatch: 0, traitGap: 0, meanCheat: 0,
          visits: 0, event: null, _t: 0
        };
      },

      step(st, ctx, dt) {
        const dd = clamp(dt / 86400, 0, maxDd);
        if (dd <= 0) return st;
        const P = st.plants, A = st.polls;
        const nP = P.length, nA = A.length;

        // 1. TRAIT-MATCH interaction + Type-II saturated visitation between every plant/pollinator pair.
        //    match = exp(-dTrait^2 / 2sigma^2); enc = visitRate*match*plantPop; a visitor saturates over all its plants.
        const match = [], enc = [];
        const sat = new Float64Array(nA);          // per-pollinator saturation factor
        const totalEnc = new Float64Array(nA);
        for (let p = 0; p < nP; p++) { match.push(new Float64Array(nA)); enc.push(new Float64Array(nA)); }
        for (let a = 0; a < nA; a++) {
          let te = 0;
          for (let p = 0; p < nP; p++) {
            const d = P[p].trait - A[a].trait;
            const m = Math.exp(-(d * d) / (2 * sigma * sigma));
            match[p][a] = m;
            const e = visitRate * m * Math.max(P[p].pop, 0);
            enc[p][a] = e; te += e;
          }
          totalEnc[a] = te;
          sat[a] = 1 / (1 + handling * te);
        }

        // 2. realized VISITS[p][a] = A[a] * enc * sat  (per day). Legit pollination = visits * (1 - cheatFrac).
        //    Reward collected per visitor: full for cooperators, reduced by sanctions for cheaters.
        const pollen = new Float64Array(nP);       // legit pollination received by each plant (~ scales with plant pop)
        const rewardCoop = new Float64Array(nA);   // per-capita reward a cooperator gets
        const rewardCheat = new Float64Array(nA);  // per-capita reward a cheater gets (sanctioned)
        const gradP = new Float64Array(nP), gradA = new Float64Array(nA); // coevolution selection gradients
        for (let a = 0; a < nA; a++) {
          const q = A[a].cheat;
          let rbase = 0;                           // per-capita full-access reward for pollinator a
          for (let p = 0; p < nP; p++) {
            const v = Math.max(A[a].pop, 0) * enc[p][a] * sat[a];   // total visits between p and a per day
            pollen[p] += v * (1 - q);              // only cooperators transfer pollen
            const perCapVisit = enc[p][a] * sat[a]; // = v / A[a].pop (per visitor), independent of A[a]
            rbase += perCapVisit * nectar;
            // coevolution gradients (dMatch/dTrait = match*(otherTrait-thisTrait)/sigma^2; visits ~ match)
            const invS2 = 1 / (sigma * sigma);
            gradP[p] += seedGain * (1 - q) * (Math.max(A[a].pop, 0) * enc[p][a] * sat[a]) * (A[a].trait - P[p].trait) * invS2;
            gradA[a] += rewardConv * nectar * (perCapVisit * Math.max(A[a].pop, 0)) * (P[p].trait - A[a].trait) * invS2;
          }
          rewardCoop[a] = rbase;
          rewardCheat[a] = rbase * (1 - sanctions);
        }

        // 3. update PLANT populations (obligate: no pollination -> only the baseline, so an unvisited plant declines)
        let plantPop = 0;
        for (let p = 0; p < nP; p++) {
          const perCapPoll = pollen[p] / Math.max(P[p].pop, EPS);
          const wP = plantBaseline + seedGain * perCapPoll;                 // per-capita fitness (births/day)
          const nx = P[p].pop + P[p].pop * (wP * (1 - P[p].pop / plantK) - plantMort) * dd;
          P[p].pop = clamp(nx, 0, plantK * 1.5);
          if (P[p].pop < 1e-4) P[p].pop = 0;                                // extinction floor
          plantPop += P[p].pop;
        }

        // 4. update POLLINATOR populations + the cheat frequency replicator
        let pollPop = 0, meanCheat = 0;
        for (let a = 0; a < nA; a++) {
          const q = A[a].cheat;
          const wCoop = pollBaseline + rewardConv * rewardCoop[a] - costService;
          const wCheat = pollBaseline + rewardConv * rewardCheat[a];
          const wA = (1 - q) * wCoop + q * wCheat;                          // mean per-capita fitness
          const nx = A[a].pop + A[a].pop * (wA * (1 - A[a].pop / pollK) - pollMort) * dd;
          A[a].pop = clamp(nx, 0, pollK * 1.5);
          if (A[a].pop < 1e-4) A[a].pop = 0;
          // replicator: cheating spreads when it out-earns cooperating. wCheat-wCoop = costService - sanctions*reward.
          if (cheatEvolve) {
            const dq = q * (1 - q) * (wCheat - wCoop) * cheatRate * dd;
            A[a].cheat = clamp(q + dq, 0, 1);
          }
          pollPop += A[a].pop;
          meanCheat += A[a].cheat;
        }
        meanCheat /= Math.max(nA, 1);

        // 5. COEVOLUTION -- climb the selection gradient toward the partner's trait (+ optional stabilizing cost)
        if (evolve) {
          for (let p = 0; p < nP; p++) {
            const g = gradP[p] / Math.max(P[p].pop, EPS);
            const dTrait = (h2 * traitVar * g - traitCost * (P[p].trait - traitOptP)) * dd;
            P[p].trait = clamp(P[p].trait + dTrait, traitLo, traitHi);
          }
          for (let a = 0; a < nA; a++) {
            const g = gradA[a] / Math.max(A[a].pop, EPS);
            const dTrait = (h2 * traitVar * g - traitCost * (A[a].trait - traitOptA)) * dd;
            A[a].trait = clamp(A[a].trait + dTrait, traitLo, traitHi);
          }
        }

        // observables
        let mm = 0, mmN = 0, gap = 0, gapN = 0, totV = 0;
        for (let p = 0; p < nP; p++) for (let a = 0; a < nA; a++) {
          const w = P[p].pop * A[a].pop;
          mm += match[p][a] * w; mmN += w;
          totV += Math.max(A[a].pop, 0) * enc[p][a] * sat[a];
        }
        // trait gap: nearest-partner distance averaged over the plants (how well matched the community is)
        for (let p = 0; p < nP; p++) {
          let best = Infinity;
          for (let a = 0; a < nA; a++) best = Math.min(best, Math.abs(P[p].trait - A[a].trait));
          if (isFinite(best)) { gap += best; gapN++; }
        }
        const prevPlant = st.plantPop, prevPoll = st.pollPop;
        st.plantPop = plantPop; st.pollPop = pollPop;
        st.meanMatch = mmN > 0 ? mm / mmN : 0;
        st.traitGap = gapN > 0 ? gap / gapN : 0;
        st.meanCheat = meanCheat;
        st.visits = totV;
        st._t += dt;
        // events
        st.event = null;
        if ((prevPlant > 0.05 && plantPop <= 0.02) || (prevPoll > 0.05 && pollPop <= 0.02)) st.event = 'collapse';
        else if (meanCheat > 0.85 && st.meanCheat >= (prevPoll ? 0 : 0)) st.event = st.event || 'cheaterTakeover';
        return st;
      },

      // OUT: the two guilds (pops + traits + cheat), the total populations, how well matched they are, visit rate.
      // Other systems / the renderer read these; a coupled world can drive plantK from vegetation, etc.
      // traitGap / meanMatch / totals are computed FRESH here (from the traits + pops) so they are valid even
      // before the first step -- observables should never depend on step-timing.
      fields(st) {
        const P = st.plants, A = st.polls;
        let plantPop = 0, pollPop = 0, meanCheat = 0;
        for (const s of P) plantPop += Math.max(s.pop, 0);
        for (const s of A) { pollPop += Math.max(s.pop, 0); meanCheat += s.cheat; }
        meanCheat /= Math.max(A.length, 1);
        let mm = 0, mmN = 0, gap = 0, gapN = 0;
        for (let p = 0; p < P.length; p++) {
          let best = Infinity;
          for (let a = 0; a < A.length; a++) {
            const d = P[p].trait - A[a].trait;
            const m = Math.exp(-(d * d) / (2 * sigma * sigma));
            const w = Math.max(P[p].pop, 0) * Math.max(A[a].pop, 0);
            mm += m * w; mmN += w;
            best = Math.min(best, Math.abs(d));
          }
          if (isFinite(best)) { gap += best; gapN++; }
        }
        return {
          plants: P, pollinators: A,
          plantPop, pollPop,
          meanMatch: mmN > 0 ? mm / mmN : 0, traitGap: gapN > 0 ? gap / gapN : 0, meanCheat,
          visits: st.visits
        };
      },
      events(st) { return st.event ? [{ kind: st.event }] : []; }
    };
  }

  return { makeMutualism };
});