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