← Natura·Docs/Modules/Source

artifact-test.js

222 lines · in natura-src-1.0.0.zip as natura/artifact-test.js · a test

// artifact-test.js -- the ARTIFICE / lifecycle-of-made-things system. Determinism, boundedness, mass
// CONSERVATION, and the four engineering-reliability emergences that no single mechanism contains -- each one
// PROVEN BY RUNNING the sim under contrasting conditions and asserting on the NUMBERS (the society-test.js
// discipline), never by reasoning:
//   (a) failures across a cohort's life trace the classic BATHTUB HAZARD curve (high infant, low flat middle,
//       rising wear-out tail) -- measured as a true hazard rate (failures / units-at-risk), not raw counts;
//   (b) MAINTENANCE lifts mean service life, but with DIMINISHING RETURNS, and there is a repair-vs-replace
//       crossover where mending stops being worth it;
//   (c) the population settles to a STEADY STATE (working / broken / scrap) under fixed produce/wear/recycle;
//   (d) recycling-with-loss makes usable material DECAY over cycles unless replenished (mass conserved,
//       usefulness not).
const N = require('./natura.js');
const { makeArtifact, STATE } = require('./artifact.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); } };

// run a world of one artifact system for `days`, return the fields snapshot
function run(seed, cfg, days) {
  const w = World({ seed, systems: [makeArtifact(cfg)] });
  for (let i = 0; i < (days || 400); i++) w.step(DAY);
  return w.fields().artifact;
}

console.log('artifact assertions:');

// ============================================================================================================
// 1. DETERMINISM -- same seed, identical history (the ledger and every counter reproduce exactly)
// ============================================================================================================
{
  const cfg = { params: { rawStock0: 800, buildRate: 6, targetStock: 250 } };
  const a = run(3, cfg, 300), b = run(3, cfg, 300);
  const same = a.built === b.built && a.failed === b.failed && a.repaired === b.repaired &&
    a.retired === b.retired && a.nWorking === b.nWorking && a.nScrap === b.nScrap &&
    Math.abs(a.rawStock - b.rawStock) < 1e-9 && Math.abs(a.entropyLost - b.entropyLost) < 1e-9;
  ok(same, 'same seed -> identical life history (built/failed/repaired/retired + the material ledger reproduce)');
}

// ============================================================================================================
// 2. BOUNDED + FINITE -- every per-artifact field stays in range; stocks never go negative; census consistent
// ============================================================================================================
{
  const w = World({ seed: 5, systems: [makeArtifact({ params: { rawStock0: 600, buildRate: 5, targetStock: 200 } })] });
  let good = true;
  for (let d = 0; d < 500; d++) {
    w.step(DAY);
    const f = w.fields().artifact;
    if (!(isFinite(f.rawStock) && f.rawStock >= -1e-6 && f.scrapStock >= -1e-6 && f.entropyLost >= -1e-6)) good = false;
    if (f.nWorking + f.nBroken + f.nScrap + f.nEmpty !== f.cap) good = false;   // census partitions the pool
    for (let i = 0; i < f.cap; i++) {
      if (f.state[i] === STATE.WORKING || f.state[i] === STATE.BROKEN) {
        if (!(isFinite(f.age[i]) && f.age[i] >= -1e-6)) good = false;
        if (!(f.condition[i] >= -1e-6 && f.condition[i] <= 1 + 1e-6)) good = false;
        if (!(isFinite(f.wear[i]) && f.wear[i] >= -1e-6)) good = false;
        if (!(f.quality[i] >= 0 && f.quality[i] <= 1)) good = false;
      }
    }
  }
  ok(good, 'ages, condition[0,1], wear>=0, quality[0,1], and the stocks stay finite/in-range; the census partitions the pool');
}

// ============================================================================================================
// 3. MASS CONSERVATION -- the closed world's total matter is invariant; entropy only MOVES mass, never destroys
//    the accounting. Every unit of matter is in exactly one place (raw / scrap-pool / product / yard / entropy).
// ============================================================================================================
{
  const M0 = 1000;
  const w = World({ seed: 6, systems: [makeArtifact({ params: { rawStock0: M0, buildRate: 8, targetStock: 300, replenish: 0 } })] });
  let maxDrift = 0, minTotal = Infinity, maxTotal = -Infinity;
  for (let d = 0; d < 800; d++) { w.step(DAY); const f = w.fields().artifact; maxDrift = Math.max(maxDrift, Math.abs(f.totalMass - M0)); minTotal = Math.min(minTotal, f.totalMass); maxTotal = Math.max(maxTotal, f.totalMass); }
  const f = w.fields().artifact;
  ok(maxDrift < 1e-6, 'CONSERVATION: closed-world total matter is invariant to ' + maxDrift.toExponential(1) + ' (stayed ' + M0 + ' every step; span ' + minTotal.toFixed(4) + '..' + maxTotal.toFixed(4) + ')');
  ok(f.entropyLost > 5, 'yet matter is steadily LOST to recycle entropy -- usefulness leaks even as mass is conserved (' + f.entropyLost.toFixed(1) + ' lost of ' + M0 + ')');
}

// ============================================================================================================
// (a) THE BATHTUB HAZARD CURVE -- the headline emergence. Manufacture ONE big cohort from a fixed raw batch
//     (then no more raw, no maintenance -> pure failure), let them all age, and measure the HAZARD RATE by age
//     = (failures in an age band) / (unit-days at risk in that band). Raw failure counts are confounded by the
//     shrinking survivor pool; the hazard rate is the honest reliability measure. It must fall from a high
//     infant-mortality shoulder to a low flat middle, then climb through a wear-out tail.
// ============================================================================================================
let bathtub = null;
{
  const batch = 5000, binW = 20;
  const w = World({ seed: 5, systems: [makeArtifact({ cap: 6000, params: {
    rawStock0: batch, buildRate: 1e6, targetStock: 0, maintain: false,   // build the whole batch in step 1, never repair
    recycleRate: 0, replenish: 0, breakdownRepairable: 0, salvageDailyP: 1.0   // failures are write-offs, cleared to scrap
  } })] });
  w.step(DAY);                                   // step 1 fills the cohort from raw
  const built = w.fields().artifact.built;
  const failBin = {}, riskDays = {};
  for (let d = 0; d < 360; d++) {
    const f = w.fields().artifact;
    for (let i = 0; i < f.cap; i++) if (f.state[i] === STATE.WORKING) { const b = Math.floor(f.age[i] / binW); riskDays[b] = (riskDays[b] || 0) + 1; }
    w.step(DAY);
    for (const e of w.events()) if (e.kind === 'failure') { const b = Math.floor(e.age / binW); failBin[b] = (failBin[b] || 0) + 1; }
  }
  const hz = {};                                 // hazard per day per age-bin, only where enough at-risk observations
  for (const k of Object.keys(riskDays)) { const rk = riskDays[k]; if (rk >= 500) hz[k] = (failBin[k] || 0) / rk; }
  const keys = Object.keys(hz).map(Number).sort((x, y) => x - y);
  const infant = hz[keys[0]];                    // youngest band -- the infant-mortality shoulder
  let trough = Infinity, troughK = 0;
  for (const k of keys) if (hz[k] < trough) { trough = hz[k]; troughK = k; }
  const tail = hz[keys[keys.length - 1]];        // oldest well-populated band -- the wear-out tail
  bathtub = { built, infant, trough, troughK, tail, binW, keys, hz };

  console.log('  bathtub hazard(/day) by age: ' + keys.map(k => (k * binW) + 'd:' + hz[k].toFixed(4)).join('  '));

  ok(built >= 4900, '(a) a full COHORT is manufactured from one raw batch (' + built + ' units)');
  ok(infant > trough * 1.6, '(a) INFANT MORTALITY: early-life hazard is elevated -- ' + infant.toFixed(5) + '/day at age <' + binW + 'd vs a ' + trough.toFixed(5) + ' trough (' + (infant / trough).toFixed(2) + 'x)');
  ok(troughK > 0 && troughK * binW <= 120, '(a) a LOW FLAT MIDDLE follows -- the hazard bottoms out in early-mid life (trough at age ' + (troughK * binW) + 'd)');
  ok(tail > trough * 5, '(a) a rising WEAR-OUT TAIL: late-life hazard climbs far above the trough -- ' + tail.toFixed(5) + '/day vs ' + trough.toFixed(5) + ' (' + (tail / trough).toFixed(1) + 'x)');
  // and the tail must be monotonically climbing over the second half of life (not just a single high bin)
  const mid = keys[Math.floor(keys.length / 2)];
  ok(hz[keys[keys.length - 1]] > hz[mid] && hz[mid] > hz[troughK], '(a) the three regimes are ORDERED across life: infant ' + infant.toFixed(4) + ' -> trough ' + trough.toFixed(4) + ' -> mid ' + hz[mid].toFixed(4) + ' -> tail ' + tail.toFixed(4) + ' (a true bathtub)');
}

// ============================================================================================================
// (b) MAINTENANCE + DIMINISHING RETURNS + the REPAIR-vs-REPLACE crossover. Run identical fleets with
//     maintenance OFF vs ON: mean service life must RISE. Then sweep how fast the repair-ceiling decays (how
//     quickly repairs lose their value): as decay grows, mean life falls back toward the no-maintenance
//     baseline and units are scrapped after FEWER repairs -- the crossover moves earlier. Diminishing returns
//     is shown directly: successive repairs reach ever-lower ceilings.
// ============================================================================================================
{
  function meanLife(maintain, decay, seed) {
    const w = World({ seed, systems: [makeArtifact({ cap: 6000, params: {
      rawStock0: 4000, buildRate: 1e6, targetStock: 0, maintain, repairCeilDecay: decay,
      recycleRate: 0, replenish: 0, salvageDailyP: 0.6, breakdownRepairable: 0.6
    } })] });
    w.step(DAY);
    let ageSum = 0, n = 0, repSum = 0;
    for (let d = 0; d < 600; d++) { w.step(DAY); for (const e of w.events()) if (e.kind === 'retired') { ageSum += e.age; n++; repSum += e.repairs; } }
    return { life: n ? ageSum / n : 0, meanRepairs: n ? repSum / n : 0, n };
  }
  const off = meanLife(false, 0.12, 5);
  const on = meanLife(true, 0.12, 5);
  ok(on.life > off.life * 1.2, '(b) MAINTENANCE lifts mean service life -- ' + off.life.toFixed(0) + 'd unmaintained -> ' + on.life.toFixed(0) + 'd maintained (' + ((on.life / off.life - 1) * 100).toFixed(0) + '% longer, avg ' + on.meanRepairs.toFixed(1) + ' repairs each)');

  const slow = meanLife(true, 0.05, 5);   // repairs stay valuable a long time
  const fast = meanLife(true, 0.30, 5);   // repairs lose value fast -> replace sooner
  ok(slow.life > fast.life + 40, '(b) DIMINISHING RETURNS govern the payoff -- when repairs decay slowly they extend life to ' + slow.life.toFixed(0) + 'd; when they decay fast, only ' + fast.life.toFixed(0) + 'd');
  ok(slow.meanRepairs > fast.meanRepairs + 1.0, '(b) the REPAIR-vs-REPLACE crossover moves earlier as repairs sour -- ' + slow.meanRepairs.toFixed(1) + ' repairs/unit when durable vs ' + fast.meanRepairs.toFixed(1) + ' when they diminish fast (fewer mends before scrap)');
  ok(fast.life < off.life * 1.15 + 40 && fast.life >= off.life, '(b) when repairs barely help, maintained life collapses toward the unmaintained baseline -- repair is no longer worth it (' + fast.life.toFixed(0) + 'd fast-decay vs ' + off.life.toFixed(0) + 'd none)');

  // diminishing returns shown DIRECTLY: successive repair ceilings on one tracked unit strictly decrease
  const w = World({ seed: 7, systems: [makeArtifact({ cap: 500, params: { rawStock0: 200, buildRate: 1e6, maintain: true, recycleRate: 0, repairCeilDecay: 0.12, repairThresh: 0.5, repairDailyP: 1.0 } })] });
  w.step(DAY);
  const ceils = [];
  for (let d = 0; d < 500 && ceils.length < 6; d++) { w.step(DAY); for (const e of w.events()) if ((e.kind === 'maintained' || e.kind === 'salvaged') && e.id === 0) ceils.push(e.ceil); }
  let decreasing = ceils.length >= 4; for (let i = 1; i < ceils.length; i++) if (ceils[i] >= ceils[i - 1]) decreasing = false;
  ok(decreasing, '(b) each successive repair reaches a LOWER ceiling -- diminishing returns, made explicit: ' + ceils.map(c => c.toFixed(2)).join(' -> '));
}

// ============================================================================================================
// (c) STEADY STATE -- under fixed produce (demand-pull to a target working fleet) / wear / recycle rates, the
//     population census settles: the working fleet holds near its target, and broken + scrap counts stabilize
//     rather than drift. A factory in flow equilibrium: production replaces losses exactly.
// ============================================================================================================
{
  const target = 400;
  const w = World({ seed: 8, systems: [makeArtifact({ cap: 4000, params: {
    rawStock0: 5000, buildRate: 30, targetStock: target, maintain: true, recycleRate: 0.5, recycleLoss: 0.05, replenish: 6.0   // mined raw keeps the loop fed so it can hold a true steady state
  } })] });
  // warm up to equilibrium, then sample the census over a long window and check it is stable
  for (let d = 0; d < 400; d++) w.step(DAY);
  let wMin = Infinity, wMax = -Infinity, wSum = 0, bSum = 0, scSum = 0, n = 0;
  for (let d = 0; d < 400; d++) { w.step(DAY); const f = w.fields().artifact; wMin = Math.min(wMin, f.nWorking); wMax = Math.max(wMax, f.nWorking); wSum += f.nWorking; bSum += f.nBroken; scSum += f.nScrap; n++; }
  const wMean = wSum / n, bMean = bSum / n, scMean = scSum / n;
  ok(Math.abs(wMean - target) < 25 && (wMax - wMin) < 60, '(c) STEADY STATE: the working fleet holds near its target ' + target + ' (mean ' + wMean.toFixed(0) + ', band ' + wMin + '..' + wMax + ') -- production replaces losses');
  ok(bMean >= 0 && scMean >= 0 && (wMax - wMin) / wMean < 0.2, '(c) the broken/scrap sub-populations stabilize alongside it (working +/-' + (((wMax - wMin) / wMean) * 100).toFixed(0) + '%, broken ~' + bMean.toFixed(0) + ', scrap ~' + scMean.toFixed(0) + ') -- a factory in flow equilibrium');
}

// ============================================================================================================
// (d) RECYCLE-WITH-LOSS drains the closed loop. With NO replenishment, cycling material through raw -> product
//     -> scrap -> recycled-raw loses recycleLoss each pass, so the USABLE stock decays monotonically over
//     cycles; with zero loss it is invariant; a loss world ends strictly poorer than a lossless one. This is
//     the second law written into a supply chain: the mass is still there (conserved), the USABLE mass is not.
// ============================================================================================================
{
  function usableSeries(loss) {
    const w = World({ seed: 9, systems: [makeArtifact({ cap: 2000, params: {
      rawStock0: 500, buildRate: 20, targetStock: 350, maintain: true, recycleRate: 0.5, recycleLoss: loss, replenish: 0
    } })] });
    const s = [];
    for (let d = 0; d < 1500; d++) { w.step(DAY); if (d % 300 === 299) s.push(w.fields().artifact.usableMass); }
    const f = w.fields().artifact;
    return { s, entropyLost: f.entropyLost, total: f.totalMass };
  }
  const lossy = usableSeries(0.08), lossless = usableSeries(0.0);
  let monotoneDown = true; for (let i = 1; i < lossy.s.length; i++) if (lossy.s[i] > lossy.s[i - 1] + 1e-6) monotoneDown = false;
  ok(monotoneDown && lossy.s[lossy.s.length - 1] < lossy.s[0] - 50, '(d) recycling-with-loss DECAYS the usable stock over cycles: ' + lossy.s.map(x => x.toFixed(0)).join(' -> ') + ' (entropy took ' + lossy.entropyLost.toFixed(0) + ')');
  let flat = true; for (let i = 1; i < lossless.s.length; i++) if (Math.abs(lossless.s[i] - lossless.s[0]) > 1e-6) flat = false;
  ok(flat, '(d) with ZERO recycle loss the usable stock is INVARIANT -- the decay is caused by entropy, not by the cycling itself (' + lossless.s.map(x => x.toFixed(0)).join(' -> ') + ')');
  ok(lossy.s[lossy.s.length - 1] < lossless.s[lossless.s.length - 1] - 100, '(d) a lossy world ends strictly poorer than a lossless one (' + lossy.s[lossy.s.length - 1].toFixed(0) + ' vs ' + lossless.s[lossless.s.length - 1].toFixed(0) + ' usable) -- unless replenished, the well runs down');
}

// ============================================================================================================
// (e) QUALITY MATTERS -- a coupled cross-check that the material-grade lever really flows through wear into
//     lifetime: a fleet forged from HIGH-grade stock outlives one forged from LOW-grade stock, all else equal.
//     (Not one of the four required emergences, but it proves the quality->wear->hazard coupling is live.)
// ============================================================================================================
{
  function meanLifeByGrade(gradeMean, seed) {
    const w = World({ seed, systems: [makeArtifact({ cap: 6000, params: {
      rawStock0: 4000, buildRate: 1e6, targetStock: 0, maintain: false, gradeMean, gradeSD: 0.05,
      defectRate: 0.05, recycleRate: 0, replenish: 0, salvageDailyP: 1.0, breakdownRepairable: 0
    } })] });
    w.step(DAY);
    let ageSum = 0, n = 0;
    for (let d = 0; d < 500; d++) { w.step(DAY); for (const e of w.events()) if (e.kind === 'failure') { ageSum += e.age; n++; } }
    return n ? ageSum / n : 0;
  }
  const lowQ = meanLifeByGrade(0.25, 5), highQ = meanLifeByGrade(0.85, 5);
  ok(highQ > lowQ * 1.15, '(e) QUALITY flows through to lifetime -- high-grade stock outlives low-grade (mean life-to-failure ' + lowQ.toFixed(0) + 'd low-Q vs ' + highQ.toFixed(0) + 'd high-Q)');
}

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