commonwealth-test.js
110 lines · in natura-src-1.0.0.zip as natura/commonwealth-test.js · a test
// commonwealth-test.js -- prove the grand coupling by RUNNING it. Two things must be true:
// (1) a stable baseline: left alone for decades the world neither explodes nor dies;
// (2) a LAGGED CASCADE: inject a drought and the shock travels downstream in ORDER --
// biomass browns first, THEN the granary empties (hunger), THEN unrest/economy respond.
// Plus the coupling channels each move the right way, and a plague can ARISE from conditions.
const { makeCommonwealth } = require('./commonwealth.js');
const { UNITS } = require('./natura.js');
let fail = 0;
const ok = (c, m) => { if (!c) { console.log(' FAIL:', m); fail++; } else console.log(' ok:', m); };
const f2 = (x) => (x == null ? 'na' : (+x).toFixed(2));
// ---- (1) BASELINE: 40 years, no perturbation ----
console.log('== baseline (40y, no shock) ==');
{
const cw = makeCommonwealth(7);
const ticks = Math.round(40 * UNITS.YEAR / cw.dt);
let maxAct = 0, sumAct = 0, foodPos = 0, minPop = 1e9, maxPop = 0;
for (let i = 0; i < ticks; i++) {
cw.step();
const r = cw.read();
if (r.harvest.foodStock > 0) foodPos++;
maxAct = Math.max(maxAct, r.economy.activity); sumAct += r.economy.activity;
minPop = Math.min(minPop, r.population.pop); maxPop = Math.max(maxPop, r.population.pop);
if (i % Math.round(5 * UNITS.YEAR / cw.dt) === 0)
console.log(` y${f2(r.year)}: pop=${f2(r.population.pop)} food=${f2(r.harvest.foodStock)} biom=${f2(r.land.biomass)} fert=${f2(r.land.fertility)} act=${f2(r.economy.activity)} unrest=${f2(r.mood.unrest)} sparks=${r.coupler.sparks}`);
}
const r = cw.read();
const meanAct = sumAct / ticks;
// the baseline is not placid -- the ~9-year wet/dry epochs make it BREATHE (endogenous famines/plagues) --
// so we test that it stays a living, recovering world: no extinction, no explosion, healthy good years.
ok(minPop > 20 && maxPop < 600, `pop stayed a living world over 40y (${f2(minPop)}..${f2(maxPop)})`);
ok(foodPos / ticks > 0.8, `granary was non-empty most of the time (${f2(100 * foodPos / ticks)}%)`);
ok(maxAct > 0.92 && meanAct > 0.7, `economy healthy in the good years and recovers (max=${f2(maxAct)} mean=${f2(meanAct)})`);
ok(r.land.fertility > 0.2, `soil fertility held (${f2(r.land.fertility)})`);
}
// ---- (2) DROUGHT CASCADE: warm up, snapshot, inject a hard drought, watch the order of response ----
console.log('\n== drought cascade (order of downstream response) ==');
{
const cw = makeCommonwealth(7);
const warm = Math.round(14 * UNITS.YEAR / cw.dt);
for (let i = 0; i < warm; i++) cw.step();
const base = cw.read();
console.log(` pre-drought: biom=${f2(base.land.biomass)} food=${f2(base.harvest.foodStock)} hunger=${f2(base.harvest.hunger)} pop=${f2(base.population.pop)} act=${f2(base.economy.activity)} unrest=${f2(base.mood.unrest)} specDev=${f2(base.speculation.deviation)}`);
cw.drought(0.92);
// capture per-tick series for the years after the shock
const post = Math.round(9 * UNITS.YEAR / cw.dt);
const series = [];
for (let i = 0; i < post; i++) { cw.step(); series.push(cw.read()); }
const dtY = cw.dt / UNITS.YEAR;
// first tick (in years-after-shock) at which each variable clearly responds in the expected direction
const respTime = (pred) => { for (let i = 0; i < series.length; i++) if (pred(series[i])) return i * dtY; return Infinity; };
const tBiom = respTime(r => r.land.biomass < base.land.biomass * 0.7);
const tHunger = respTime(r => r.harvest.hunger > Math.max(0.25, base.harvest.hunger + 0.2));
const tUnrest = respTime(r => r.mood.unrest > base.mood.unrest + 0.15);
const tAct = respTime(r => r.economy.activity < base.economy.activity * 0.85);
const peak = (sel) => series.reduce((m, r) => Math.max(m, sel(r)), -1e9);
console.log(` response times (yr after shock): biomass=${f2(tBiom)} hunger=${f2(tHunger)} economy=${f2(tAct)} unrest=${f2(tUnrest)}`);
console.log(` peaks after shock: hunger=${f2(peak(r => r.harvest.hunger))} unrest=${f2(peak(r => r.mood.unrest))} specDev=${f2(peak(r => r.speculation.deviation))} maxInf=${f2(peak(r => r.epidemic.infectious))}`);
console.log(` troughs: minBiom=${f2(-peak(r => -r.land.biomass))} minFood=${f2(-peak(r => -r.harvest.foodStock))} minAct=${f2(-peak(r => -r.economy.activity))} minPop=${f2(-peak(r => -r.population.pop))}`);
ok(tBiom < tHunger, `biomass browns BEFORE the granary empties (${f2(tBiom)} < ${f2(tHunger)})`);
ok(tHunger < tUnrest, `hunger rises BEFORE unrest (${f2(tHunger)} < ${f2(tUnrest)})`);
ok(tBiom < 1.5, `biomass responds within a season or two (${f2(tBiom)}y)`);
ok(peak(r => r.harvest.hunger) > 0.5, `the drought caused real hunger (peak ${f2(peak(r => r.harvest.hunger))})`);
ok(peak(r => r.speculation.deviation) > base.speculation.deviation + 0.1 || peak(r => r.speculation.deviation) > 1.3, `scarcity drove a grain-price mania (peak dev ${f2(peak(r => r.speculation.deviation))})`);
ok(-peak(r => -r.economy.activity) < base.economy.activity * 0.9, `the economy contracted (minAct ${f2(-peak(r => -r.economy.activity))} vs ${f2(base.economy.activity)})`);
ok(peak(r => r.mood.unrest) > base.mood.unrest + 0.15, `unrest climbed (peak ${f2(peak(r => r.mood.unrest))})`);
}
// ---- (3) PLAGUE FROM CONDITIONS: over a long, drought-battered run, a plague ignites on its own ----
console.log('\n== plague arises from crowding x malnutrition ==');
{
const cw = makeCommonwealth(3);
const ticks = Math.round(60 * UNITS.YEAR / cw.dt);
let maxInf = 0, sparks = 0, deadTotal, maxTrans = 0, maxDens = 0;
for (let i = 0; i < ticks; i++) {
// press the world with a fresh drought every ~11 years so famines recur
if (i % Math.round(11 * UNITS.YEAR / cw.dt) === Math.round(6 * UNITS.YEAR / cw.dt)) cw.drought(0.9);
cw.step(); const r = cw.read();
maxInf = Math.max(maxInf, r.epidemic.infectious);
maxTrans = Math.max(maxTrans, r.coupler.transmiss); maxDens = Math.max(maxDens, r.coupler.density);
sparks = r.coupler.sparks; deadTotal = r.epidemic.dead;
}
console.log(` over 60y: sparks=${sparks} maxInfectious=${f2(maxInf)} cumulativeDead=${f2(deadTotal)} maxTransmiss=${f2(maxTrans)} maxDensity=${f2(maxDens)}`);
ok(sparks > 0, `at least one plague ignited from conditions (${sparks} sparks)`);
ok(maxInf > 0.05, `an ignited plague actually spread (peak infectious ${f2(maxInf)})`);
}
// ---- (4) THE MALTHUSIAN ESCAPE: for a long time the trap holds, then knowledge compounds and pop breaks the ceiling ----
console.log('\n== the Malthusian escape (trap, then takeoff) ==');
{
const cw = makeCommonwealth(7);
const per = Math.round(UNITS.YEAR / cw.dt);
let knowAt50 = 0, popAt50 = 0, popTrapMax = 0;
for (let y = 0; y <= 320; y++) {
for (let i = 0; i < per; i++) cw.step();
const r = cw.read();
if (y <= 90) popTrapMax = Math.max(popTrapMax, r.population.pop); // the ceiling before the takeoff
if (y === 50) { knowAt50 = r.knowledge.level; popAt50 = r.population.pop; }
}
const r = cw.read();
console.log(` y50: know=${f2(knowAt50)} pop=${f2(popAt50)} | trap-era pop ceiling=${f2(popTrapMax)} | y320: know=${f2(r.knowledge.level)} pop=${f2(r.population.pop)}`);
ok(knowAt50 < 0.6, `the trap holds early -- knowledge barely moves by year 50 (${f2(knowAt50)})`);
ok(r.knowledge.level > 3, `knowledge eventually compounds to a modern plateau (${f2(r.knowledge.level)})`);
ok(r.population.pop > popTrapMax * 1.6, `population BREAKS the old ceiling after the takeoff (${f2(r.population.pop)} vs ceiling ${f2(popTrapMax)})`);
}
console.log(fail ? `\nFAILED (${fail})` : '\nALL PASS');
process.exit(fail ? 1 : 0);