← Natura·Docs/Modules/Source

city-test.js

206 lines · in natura-src-1.0.0.zip as natura/city-test.js · a test

// city-test.js -- the CITY system, a BUILT-WORLD grid, proven the society-test way: by RUNNING it and
// asserting on NUMBERS, never by reasoning about what the code "should" do. Determinism + boundedness, then
// the four coupled emergences the operator asked for, each measured:
//   (a) a monocentric CORE rises from a flat, uniform plain -- peak density climbs and density CONCENTRATES
//       (agglomeration), a downtown with nobody planning it;
//   (b) adding a ROAD induces development along it (vs a no-road control) AND that new road CONGESTS;
//   (c) as the core chokes on its own traffic, activity SPREADS and SUB-CENTRES bud off it -- the peak
//       plateaus while congestion keeps rising and secondary density clusters appear (monocentric->polycentric);
//   (d) an aged, under-valued district DECAYS, its blight SPREADS to neighbours, and where value recovers it is
//       cleared and REDEVELOPED denser (the renewal cycle).
// All randomness is seeded (init(rng) + ctx.rng); time is dt seconds. `node city-test.js` runs standalone.
const N = require('./natura.js');
const { makeCity } = require('./city.js');
const { World, UNITS } = N;

let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log('  FAIL: ' + m); } };

// step the city `months` sim-months and return {world, fields, city-state}
function grow(seed, cfg, months) {
  const w = World({ seed, systems: [makeCity(cfg || {})] });
  for (let i = 0; i < months; i++) w.step(UNITS.MONTH);
  return w;
}
// grid center as the system founds it (Math.round of the midpoint)
const CTR = (n) => Math.round((n - 1) / 2);
// density Gini over developed parcels -- 0 = every parcel equal (a flat plain), high = concentrated (a core)
function densityGini(f) {
  const ds = []; for (let n = 0; n < f.N * f.N; n++) if (f.density[n] > 0.01) ds.push(f.density[n]);
  if (!ds.length) return 0; ds.sort((a, b) => a - b);
  let cum = 0, tot = ds.reduce((a, b) => a + b, 0), g = 0;
  for (let k = 0; k < ds.length; k++) { cum += ds[k]; g += cum / tot; }
  return 1 - 2 * g / ds.length + 1 / ds.length;
}
// count DISTINCT sub-centres: local density maxima (>=8 floors), at least `minDist` cells from the core, and
// at least 4 cells apart from each other (so one blob counts once, not once per plateau cell).
function subCentres(f, minDist) {
  const n = f.N, c = (n - 1) / 2, cand = [];
  for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) {
    const m = j * n + i, d = f.density[m]; if (d < 8) continue;
    let isMax = true;
    for (let dj = -3; dj <= 3 && isMax; dj++) for (let di = -3; di <= 3; di++) { const ii = i + di, jj = j + dj; if (ii < 0 || ii >= n || jj < 0 || jj >= n) continue; if (f.density[jj * n + ii] > d + 1e-6) { isMax = false; break; } }
    if (isMax) cand.push({ i, j, d, dist: Math.hypot(i - c, j - c) });
  }
  cand.sort((a, b) => b.d - a.d);
  const chosen = [];
  for (const x of cand) { if (x.dist < minDist) continue; let okc = true; for (const y of chosen) if (Math.hypot(x.i - y.i, x.j - y.j) < 4) { okc = false; break; } if (okc) chosen.push(x); }
  return chosen.length;
}

console.log('city assertions:');

// 1. DETERMINISM -- the whole city (a hundred coupled parcels) reproduces exactly from its seed
{
  const fa = grow(1, { N: 20 }, 80).fields().city, fb = grow(1, { N: 20 }, 80).fields().city;
  let same = fa.totalPop === fb.totalPop && fa.peakDensity === fb.peakDensity && fa.developed === fb.developed;
  for (let n = 0; n < fa.density.length && same; n++) if (fa.density[n] !== fb.density[n] || fa.landValue[n] !== fb.landValue[n] || fa.use[n] !== fb.use[n]) same = false;
  const diff = grow(2, { N: 20 }, 80).fields().city;
  ok(same, 'same seed -> an identical city (every parcel: use, density, land value reproduce)');
  ok(diff.totalPop !== fa.totalPop, 'a different seed grows a different city (pop ' + fa.totalPop.toFixed(0) + ' vs ' + diff.totalPop.toFixed(0) + ')');
}

// 2. BOUNDED + FINITE -- no runaway: every field stays finite, densities/blight in range, the city reaches
//    a finite size instead of exploding (the coupled feedbacks are stabilized, not divergent)
{
  const f = grow(3, { N: 24 }, 260).fields().city;
  let good = isFinite(f.totalPop) && isFinite(f.totalJobs) && isFinite(f.peakDensity) && f.peakDensity <= 12 + 1e-6;
  for (const [F, hi] of [[f.density, 12], [f.blight, 1], [f.landValue, 1e6]]) for (let n = 0; n < F.length; n++) { const v = F[n]; if (!isFinite(v) || v < -1e-6 || v > hi + 1e-3) good = false; }
  ok(good, 'every field stays finite and in range after 260 months (no runaway); peak density ' + f.peakDensity.toFixed(2) + ' <= 12');
  ok(f.totalPop > 200 && f.totalPop < 20000, 'the city reaches a FINITE balanced size, neither ghost town nor explosion (pop ' + f.totalPop.toFixed(0) + ', jobs ' + f.totalJobs.toFixed(0) + ')');
}

// 3. (a) A MONOCENTRIC CORE EMERGES FROM A FLAT PLAIN -- density concentrates via agglomeration. The city
//    starts as a near-flat seed (gini ~0) and grows a downtown: peak density climbs many-fold and the density
//    Gini rises sharply (a few parcels hold most of the built floor space). Nobody planned the core; the
//    highest-bid-wins land market + the agglomeration feedback concentrated it.
{
  const w = World({ seed: 1, systems: [makeCity({ N: 24 })] });
  w.step(UNITS.MONTH); const f0 = w.fields().city;
  const peak0 = f0.peakDensity, gini0 = densityGini(f0);
  for (let i = 0; i < 60; i++) w.step(UNITS.MONTH);
  const f1 = w.fields().city; const peak1 = f1.peakDensity, gini1 = densityGini(f1);
  ok(peak1 > peak0 * 4 && peak1 > 9, 'a CORE rises: peak density climbs many-fold from the flat seed (' + peak0.toFixed(1) + ' -> ' + peak1.toFixed(1) + ' floors)');
  ok(gini1 > gini0 + 0.25 && gini1 > 0.35, 'density CONCENTRATES -- the density Gini jumps as a downtown forms (' + gini0.toFixed(3) + ' flat -> ' + gini1.toFixed(3) + ' concentrated)');
  // the peak sits near the founded centre, not at a random edge (it is a CENTRE)
  const n = f1.N, c = CTR(n); let pi = 0, pj = 0, pd = 0;
  for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) if (f1.density[j * n + i] > pd) { pd = f1.density[j * n + i]; pi = i; pj = j; }
  ok(Math.hypot(pi - c, pj - c) < 4, 'the density PEAK sits at the centre, not a random edge -- a true monocentric core (peak ' + Math.hypot(pi - c, pj - c).toFixed(1) + ' cells from centre)');
  // land uses SORT: commercial wins the centre, residential rings it (bid-rent segregation)
  let comNearC = 0, comFar = 0;
  for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) { if (f1.use[j * n + i] !== 2) continue; (Math.hypot(i - c, j - c) < 4 ? (comNearC++) : (comFar++)); }
  ok(comNearC > 0, 'COMMERCIAL activity sorts toward the centre (the steepest bidder wins the core: ' + comNearC + ' central commercial parcels)');
}

// 4. (b) A ROAD INDUCES DEVELOPMENT + CONGESTS -- the accessibility-development coupling, proven against a
//    no-road CONTROL. Two identical cities; in one we lay a road SPUR out from the centre into empty land.
//    Development crawls along the spur (ribbon development) where the control stays empty, AND the spur fills
//    with commuter traffic that congests it (flow on the new road vs zero on the control's road-less corridor).
{
  function runSpur(addSpur) {
    const w = World({ seed: 2, systems: [makeCity({ N: 24 })] });
    const st = w.get('city'); const n = st.N, c = CTR(n);
    for (let i = 0; i < 40; i++) w.step(UNITS.MONTH);           // establish the city first
    const rowSpur = c - 5, cells = [];                           // a corridor 5 rows off the E-W artery
    for (let i = c + 1; i < n; i++) { cells.push(rowSpur * n + i); if (addSpur) st.road[rowSpur * n + i] = 1; }
    for (let i = 0; i < 70; i++) w.step(UNITS.MONTH);            // let development respond to the new access
    const f = w.fields().city; let dev = 0, dens = 0, flow = 0;
    for (const m of cells) { if (f.use[m] !== 0) dev++; dens += f.density[m]; if (f.flow[m] > flow) flow = f.flow[m]; }
    return { dev, dens, flow };
  }
  const spur = runSpur(true), ctrl = runSpur(false);
  ok(spur.dev >= ctrl.dev + 4 && spur.dev >= 6, 'a ROAD induces development along it -- ribbon growth in the corridor vs the no-road control (' + spur.dev + ' developed parcels with the spur vs ' + ctrl.dev + ' without)');
  ok(spur.dens > ctrl.dens * 2, 'the spur corridor packs far more built density than the control (' + spur.dens.toFixed(0) + ' vs ' + ctrl.dens.toFixed(0) + ' floors)');
  ok(spur.flow > 3 && ctrl.flow < 0.5, 'the new road fills with commuter traffic and CONGESTS, where the road-less control carries none (flow ' + spur.flow.toFixed(1) + ' on the spur vs ' + ctrl.flow.toFixed(1) + ' control)');
}

// 5. (c) MONOCENTRIC -> POLYCENTRIC -- as the core congests, activity disperses and SUB-CENTRES appear. Run
//    long: the peak density PLATEAUS (the core saturates and its own traffic caps it) while congestion keeps
//    climbing and distinct secondary density clusters, away from the core, grow in number. A single downtown
//    becomes a polycentric metropolis, driven by the endogenous congestion feedback (no exogenous nudge).
{
  const w = World({ seed: 3, systems: [makeCity({ N: 28 })] });
  const snap = {};
  for (let i = 1; i <= 260; i++) { w.step(UNITS.MONTH); if (i === 40 || i === 90 || i === 260) { const f = w.fields().city; snap[i] = { peak: f.peakDensity, cong: f.maxCongestion, sub: subCentres(f, 5), dev: f.developed }; } }
  ok(snap[260].peak <= snap[90].peak + 0.15 && snap[90].peak > 11, 'the core PLATEAUS -- peak density saturates and stops climbing (' + snap[40].peak.toFixed(1) + ' -> ' + snap[90].peak.toFixed(1) + ' -> ' + snap[260].peak.toFixed(1) + ' floors)');
  ok(snap[260].cong > snap[40].cong * 3 && snap[260].cong > 20, 'yet CONGESTION keeps rising as the metro fills -- the core chokes on its own traffic (' + snap[40].cong.toFixed(1) + 'x -> ' + snap[260].cong.toFixed(1) + 'x)');
  ok(snap[260].sub > snap[40].sub && snap[260].sub >= 2, 'SUB-CENTRES bud off the core -- distinct secondary density clusters appear far from downtown (' + snap[40].sub + ' -> ' + snap[260].sub + ' sub-centres), a monocentric -> POLYCENTRIC shift');
  ok(snap[260].dev > snap[40].dev * 2, 'activity SPREADS outward as the centre saturates (' + snap[40].dev + ' -> ' + snap[260].dev + ' developed parcels)');
}

// 6. (d) BLIGHT + RENEWAL -- the two arms of the filtering cycle, each forced and measured.
//    ARM 1 (decay + spread + abandonment): a dense district with NO road access (its value can never support
//    what stands there) DECAYS -- blight climbs, its buildings empty, and the blight SPREADS to neighbours.
//    ARM 2 (renewal): a well-accessed but aged, run-down district, whose land is worth more cleared than kept,
//    is REDEVELOPED -- torn down and rebuilt DENSER than the tired stock it replaced.
{
  // ARM 1 -- doomed no-access district in a far corner
  {
    const w = World({ seed: 8, systems: [makeCity({ N: 24 })] });
    const st = w.get('city'); const n = st.N;
    for (let i = 0; i < 50; i++) w.step(UNITS.MONTH);
    const ci = 2, cj = 2, dist = [], nbr = [];
    for (let a = 0; a <= 1; a++) for (let b = 0; b <= 1; b++) { const m = (cj + a) * n + (ci + b); st.use[m] = 1; st.density[m] = 8; st.age[m] = 40; st.blight[m] = 0.3; st.pop[m] = 20; dist.push(m); }
    for (let a = -2; a <= 3; a++) for (let b = -2; b <= 3; b++) { const i = ci + b, j = cj + a; if (i < 0 || i >= n || j < 0 || j >= n) continue; if (a >= 0 && a <= 1 && b >= 0 && b <= 1) continue; const m = j * n + i; if (st.use[m] === 0) { st.use[m] = 1; st.density[m] = 4; st.age[m] = 35; } nbr.push(m); }
    let abandon = 0, peakB = 0, peakNB = 0, d0 = dist.reduce((a, m) => a + st.density[m], 0) / dist.length;
    for (let i = 0; i < 120; i++) {
      w.step(UNITS.MONTH); for (const e of w.events()) if (e.kind === 'abandon') abandon++;
      const f = w.fields().city;
      peakB = Math.max(peakB, dist.reduce((a, m) => a + f.blight[m], 0) / dist.length);
      peakNB = Math.max(peakNB, nbr.reduce((a, m) => a + f.blight[m], 0) / nbr.length);
    }
    const f = w.fields().city; const dEnd = dist.reduce((a, m) => a + f.density[m], 0) / dist.length;
    ok(peakB > 0.8, 'a cut-off, under-valued district DECAYS -- blight climbs toward abandonment (peak district blight ' + peakB.toFixed(2) + ')');
    ok(peakNB > 0.4, 'blight SPREADS to neighbouring parcels (contagion) -- peak neighbour blight ' + peakNB.toFixed(2) + ' from a clean start');
    ok(abandon > 5 && dEnd < d0, 'the decayed buildings are ABANDONED -- the district empties out (' + abandon + ' abandonment events, density ' + d0.toFixed(1) + ' -> ' + dEnd.toFixed(1) + ')');
  }
  // ARM 2 -- renewal of an aged but valuable near-core district
  {
    const w = World({ seed: 7, systems: [makeCity({ N: 24 })] });
    const st = w.get('city'); const n = st.N, c = CTR(n);
    for (let i = 0; i < 80; i++) w.step(UNITS.MONTH);
    const di = c + 2, dj = c, dist = [];
    let dBefore = 0;
    for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) { const m = (dj + a) * n + (di + b); if (st.use[m] === 0) continue; dist.push(m); st.age[m] = 50; st.blight[m] = 0.5; st.density[m] = Math.min(st.density[m], 4); dBefore += st.density[m]; }
    dBefore /= Math.max(1, dist.length);
    let renew = 0;
    for (let i = 0; i < 100; i++) { w.step(UNITS.MONTH); for (const e of w.events()) if (e.kind === 'renew' && dist.includes(e.j * n + e.i)) renew++; }
    const f = w.fields().city; const dAfter = dist.reduce((a, m) => a + f.density[m], 0) / dist.length;
    ok(renew > 0, 'an aged, valuable district is REDEVELOPED where its land outvalues the tired stock on it (' + renew + ' renewal events fired)');
    ok(dAfter > dBefore + 1.5, 'renewal rebuilds DENSER than the run-down stock it cleared (density ' + dBefore.toFixed(1) + ' -> ' + dAfter.toFixed(1) + ' floors)');
  }
}

// (e) COUPLING -- the city's congestion curve GROUNDED in a traffic microsim's fundamental diagram (opt-in).
// The standalone city assumes a BPR power law; coupled to traffic it reads the IDM's analytic volume-delay curve
// instead -- exactly the "missing car-following layer" traffic.js names. Measured, never reasoned.
{
  const { makeTraffic } = require('./traffic.js');
  const months = 90;
  // (i) clean opt-in: congestFrom:null must reproduce the standalone city byte-for-byte (existing tests untouched)
  const plain = grow(4, { N: 20 }, months).fields().city;
  const nullC = grow(4, { N: 20, congestFrom: null }, months).fields().city;
  let identical = true;
  for (let n = 0; n < plain.N * plain.N; n++) if (plain.density[n] !== nullC.density[n]) { identical = false; break; }
  ok(identical, 'coupling is a clean opt-in: congestFrom:null is byte-identical to the standalone city');

  // a coupled world: traffic exposes the fundamental diagram, the city reads it as its volume-delay curve
  const cw = World({ seed: 4, systems: [makeTraffic({ roadLen: 500, N: 24, seed: 4 }), makeCity({ N: 20, congestFrom: 'traffic' })] });
  for (let i = 0; i < months; i++) cw.step(UNITS.MONTH);
  const cf = cw.fields().city, tf = cw.fields().traffic;

  // (ii) the FD is real: a rising volume-delay curve from a positive capacity
  ok(tf.capacity > 0 && tf.vdCost && tf.vdCost.length > 1 && tf.vdCost[0] < tf.vdCost[tf.vdCost.length - 1],
    'traffic exposes a real fundamental diagram (capacity ' + tf.capacity + ' veh/hr, a monotone rising volume-delay curve)');
  // (iii) that curve is actually READ: same seed + city, only the congestion law swapped -> the city changes
  let ndiff = 0;
  for (let n = 0; n < plain.N * plain.N; n++) if (Math.abs(plain.density[n] - cf.density[n]) > 1e-3) ndiff++;
  ok(ndiff > 0, 'grounding congestion in the IDM curve CHANGES the city vs the assumed BPR (' + ndiff + ' parcels differ)');
  // (iv) the grounded city is still sane + monocentric (the swap does not break it)
  const finite = isFinite(cf.peakDensity) && isFinite(cf.maxCongestion) && isFinite(cf.totalPop);
  ok(finite && cf.totalPop > 0 && cf.peakDensity > 2 && cf.maxCongestion > 1,
    'the IDM-grounded city stays sane + monocentric (pop ' + cf.totalPop.toFixed(0) + ', peak density ' + cf.peakDensity.toFixed(1) + ', maxCongestion ' + cf.maxCongestion.toFixed(2) + ')');
}

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