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