← Natura·Docs/Modules/Source

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