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