← Natura·Docs/Modules/Source

mutualism-test.js

111 lines · in natura-src-1.0.0.zip as natura/mutualism-test.js · a test

// mutualism-test.js -- assertions for MUTUALISM / COEVOLUTION. Every emergence proven by RUNNING (on vs off),
// never by reasoning: reciprocal coupling, the obligate Allee threshold, coevolutionary trait-matching +
// syndrome sorting, and the cheater/sanctions problem.
const N = require('./natura.js');
const { makeMutualism } = require('./mutualism.js');
const { World, UNITS } = N;
const DAY = UNITS.DAY;

let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log('  FAIL: ' + m); } };
const build = (cfg) => World({ seed: 3, systems: [makeMutualism(cfg || {})] });
const days = (w, d) => { const n = Math.round(d / 2); for (let i = 0; i < n; i++) w.step(2 * DAY); };
const F = (w) => w.fields().mutualism;

console.log('mutualism assertions:');

// 1. DETERMINISM
{
  const a = build({}); const b = build({}); days(a, 500); days(b, 500);
  const fa = F(a), fb = F(b);
  ok(fa.plantPop === fb.plantPop && fa.pollPop === fb.pollPop, 'same seed -> identical run (reproducible)');
}

// 2. FINITE + BOUNDED
{
  const w = build({ plants: { n: 2, pop: 0.5 }, pollinators: { n: 2, pop: 0.5 }, evolve: true });
  let okAll = true;
  for (let i = 0; i < 400; i++) { w.step(2 * DAY); const f = F(w);
    if (!(isFinite(f.plantPop) && isFinite(f.pollPop) && f.plantPop >= 0 && f.pollPop >= 0 && f.plantPop < 10 && f.pollPop < 10)) okAll = false;
    for (const s of f.plants) if (!(s.trait >= 0 && s.trait <= 1 && s.pop >= 0)) okAll = false;
  }
  ok(okAll, 'populations + traits stay finite and bounded');
}

// 3. E1 -- RECIPROCAL COUPLING: from a viable seed both guilds climb TOGETHER to a coexistence equilibrium
{
  const w = build({});
  const p0 = F(w).plantPop, a0 = F(w).pollPop;
  days(w, 600);
  const f = F(w);
  ok(f.plantPop > p0 && f.pollPop > a0, 'both partners GROW from the seed (they lift each other, not a chase): plant ' + p0.toFixed(2) + '->' + f.plantPop.toFixed(2) + ', pollinator ' + a0.toFixed(2) + '->' + f.pollPop.toFixed(2));
  ok(f.plantPop > 0.3 && f.pollPop > 0.3, 'a stable coexistence with both present (plant ' + f.plantPop.toFixed(2) + ', pollinator ' + f.pollPop.toFixed(2) + ')');
}

// 3b. E1 -- LINKED FATES: remove the pollinators and the (obligate) plant declines -- neither stands alone
{
  const w = build({}); days(w, 600);
  const before = F(w).plantPop;
  ok(before > 0.3, 'plant established with its pollinator (' + before.toFixed(2) + ')');
  w.get('mutualism').polls.forEach(s => { s.pop = 0; });   // the pollinators vanish
  days(w, 200);
  const after = F(w).plantPop;
  ok(after < before * 0.3, 'without its pollinator the obligate plant COLLAPSES (' + before.toFixed(2) + ' -> ' + after.toFixed(2) + ') -- their fates are linked');
}

// 4. E2 -- THE ALLEE THRESHOLD: an obligate founder pair below a critical density dies; above it, it thrives (bistable)
{
  const low = build({ plants: { n: 1, pop: 0.06 }, pollinators: { n: 1, pop: 0.06 } });
  const high = build({ plants: { n: 1, pop: 0.6 }, pollinators: { n: 1, pop: 0.6 } });
  days(low, 500); days(high, 500);
  const fl = F(low), fh = F(high);
  ok(fl.plantPop < 0.02 && fl.pollPop < 0.02, 'a TOO-SMALL founder pair collapses to extinction (plant ' + fl.plantPop.toFixed(3) + ', pollinator ' + fl.pollPop.toFixed(3) + ')');
  ok(fh.plantPop > 0.3 && fh.pollPop > 0.3, 'a pair ABOVE the threshold climbs to a high mutual equilibrium (plant ' + fh.plantPop.toFixed(2) + ', pollinator ' + fh.pollPop.toFixed(2) + ')');
  ok(true, 'same rules, two fates from the starting density alone -> mutualism is BISTABLE (an Allee/quorum effect)');
}

// 5. E3 -- COEVOLUTION: a mismatched plant + pollinator evolve their traits TOWARD each other (Darwin's orchid).
//    Facultative baselines keep both alive while the traits catch up, isolating coevolution from the Allee collapse.
{
  const fac = { plantBaseline: 0.22, pollBaseline: 0.22, cheatEvolve: false };
  const evoOn = build(Object.assign({ plants: [{ trait: 0.3, pop: 0.5 }], pollinators: [{ trait: 0.7, pop: 0.5 }], evolve: true }, fac));
  const evoOff = build(Object.assign({ plants: [{ trait: 0.3, pop: 0.5 }], pollinators: [{ trait: 0.7, pop: 0.5 }], evolve: false }, fac));
  const gap0 = F(evoOn).traitGap;
  days(evoOn, 4000); days(evoOff, 4000);
  const gOn = F(evoOn).traitGap, gOff = F(evoOff).traitGap;
  ok(gap0 > 0.3, 'they start MISMATCHED (trait gap ' + gap0.toFixed(2) + ')');
  ok(gOff > 0.3, 'without evolution the mismatch persists (gap ' + gOff.toFixed(2) + ')');
  ok(gOn < 0.12, 'WITH evolution the corolla + tongue traits CONVERGE toward a match (gap ' + gap0.toFixed(2) + ' -> ' + gOn.toFixed(2) + ')');
  ok(F(evoOn).meanMatch > F(evoOff).meanMatch + 0.3, 'the coevolved pair interacts far more efficiently (match ' + F(evoOff).meanMatch.toFixed(2) + ' -> ' + F(evoOn).meanMatch.toFixed(2) + ')');
}

// 5b. E3 -- SYNDROME SORTING: 2 plants + 2 pollinators sort into matched pairs, not one averaged blob
{
  // narrow interaction niches (small matchWidth) make the generalist worse than a specialist -> DISRUPTIVE
  // selection sorts the community into matched pairs, instead of everyone converging to one average flower.
  const fac = { plantBaseline: 0.22, pollBaseline: 0.22, cheatEvolve: false, matchWidth: 0.08 };
  const w = build(Object.assign({ plants: [{ trait: 0.25, pop: 0.5 }, { trait: 0.75, pop: 0.5 }], pollinators: [{ trait: 0.3, pop: 0.5 }, { trait: 0.7, pop: 0.5 }], evolve: true }, fac));
  days(w, 6000);
  const P = w.get('mutualism').plants, A = w.get('mutualism').polls;
  const plantSpread = Math.abs(P[0].trait - P[1].trait);
  const gap = F(w).traitGap;
  ok(plantSpread > 0.35, 'the two plant species stay DISTINCT (they do not collapse to one blob): spread ' + plantSpread.toFixed(2));
  ok(gap < 0.12, 'each plant locks onto a matched pollinator -> pollination SYNDROMES (mean nearest-partner gap ' + gap.toFixed(2) + ')');
}

// 6. E4 -- THE CHEATER & SANCTIONS: a nectar-robber invades an un-defended mutualism and crashes it;
//    a plant that withholds reward from cheaters holds cooperation stable.
{
  const base = { plants: { n: 1, pop: 0.6 }, pollinators: [{ trait: 0.5, pop: 0.6, cheat: 0.12 }], cheatEvolve: true };
  const noSanc = build(Object.assign({ sanctions: 0.0 }, base));
  const sanc = build(Object.assign({ sanctions: 0.85 }, base));
  days(noSanc, 500); days(sanc, 500);
  const fn = F(noSanc), fs = F(sanc);
  ok(fn.meanCheat > 0.8, 'WITHOUT sanctions the cheater strategy INVADES (cheat frequency -> ' + fn.meanCheat.toFixed(2) + ')');
  ok(fn.plantPop < 0.1, 'and the un-pollinated mutualism COLLAPSES (plant ' + fn.plantPop.toFixed(3) + ') -- the tragedy of the commons');
  ok(fs.meanCheat < 0.25, 'WITH sanctions cheating is unprofitable and stays rare (cheat frequency ' + fs.meanCheat.toFixed(2) + ')');
  ok(fs.plantPop > 0.3, 'and cooperation PERSISTS (plant ' + fs.plantPop.toFixed(2) + ') -- partner choice stabilises the mutualism');
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);