artifact.js
371 lines · in natura-src-1.0.0.zip as natura/artifact.js · a module · its entry in the module reference · its test: artifact-test.js
// artifact.js -- Natura ARTIFICE (a domain the framework has never touched, and deliberately NOT nature):
// the LIFE-CYCLE OF MADE THINGS. Where lifecycle.js models a creature's birth/growth/death, this models the
// birth/wear/failure/repair/death of MATERIALS and ARTIFACTS -- a chair, a bearing, a blade. It is the
// engineering-reliability twin of the biological continuum: matter is drawn from a finite raw STOCK, forged
// with labour into artifacts (each carrying a fixed material QUALITY, and an AGE, CONDITION, and accumulated
// WEAR/fatigue), put to USE where it wears, fails on a realistic bathtub HAZARD, is MAINTAINED at a cost with
// diminishing returns until repair no longer beats replacement, and finally SCRAPPED -- its matter recycled
// back toward raw, losing a fraction to entropy each turn. Five coupled mechanisms, one closed material loop.
//
// The emergences (all proven by RUNNING in artifact-test.js, never by reasoning):
// (a) failures across a cohort trace the classic BATHTUB curve -- high infant mortality, a low flat middle,
// a rising wear-out tail;
// (b) MAINTENANCE lifts mean service life, but with DIMINISHING RETURNS, and there is a repair-vs-replace
// crossover where a thing is better thrown away than mended;
// (c) the population settles to a STEADY STATE (working vs broken vs scrap) under fixed produce/wear/recycle
// rates -- a factory in flow equilibrium;
// (d) recycling-with-loss makes the usable material stock DECAY over many cycles unless replenished -- the
// second law written into a supply chain (mass is conserved; usefulness is not).
//
// A standalone system by default (an autarkic little economy), but it COUPLES: it will read a labour/energy
// supply from another system's fields if wired (ctx.fields[labourName].supply throttles production), and it
// EXPOSES the whole material ledger + population census + reliability observables for a viz or an economy on
// top. Deterministic by construction: all randomness comes from the seeded rng (init) and ctx.rng (step);
// never Date.now / Math.random. Time is dt (seconds). UMD (node test / window.Artifact).
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Artifact = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
// artifact states -- EMPTY is a free pool slot; SCRAP is a failed/retired body awaiting recycle (holds its
// matter until the recycler reclaims it, exactly as a carcass holds matter until it decays).
const EMPTY = 0, WORKING = 1, BROKEN = 2, SCRAP = 3;
function makeArtifact(config) {
config = config || {};
const cap = config.cap || 4000; // artifact pool size (max simultaneous items)
const labourName = config.labour || 'labour'; // a system exposing { supply } (0..1) throttles output; optional
const P = Object.assign({
// --- the finite material world (arbitrary mass units) --------------------------------------------
rawStock0: 1000, // raw material on hand at t0
scrapStock0: 0, // scrap awaiting recycle at t0
massPerUnit: 1.0, // matter locked into one artifact
// --- MANUFACTURE ---------------------------------------------------------------------------------
buildRate: 6.0, // artifacts/day the shop can start when raw + labour allow (before throttles)
targetStock: 0, // if >0, produce only while WORKING population is below this (demand-pull)
labourPerUnit: 1.0, // labour drawn per unit built; scaled against the coupled supply if present
gradeMean: 0.62, gradeSD: 0.16, // material QUALITY of a new artifact ~ N(mean,sd) clamped 0..1 (the grade of stock)
defectRate: 0.14, // fraction of builds carrying a latent BIRTH DEFECT (drives infant mortality)
defectSeverity: 1.0, // how strongly a defect inflates early hazard
// --- USE + WEAR ----------------------------------------------------------------------------------
useRate: 1.0, // duty: fraction of time in active use (0 shelf, 1 hammered)
stress: 1.0, // load severity multiplier on wear
wearRate: 0.010, // baseline condition lost per day at useRate=1, stress=1, quality=0.5
fatiguePerWear: 1.0, // how fast irreversible fatigue accrues with condition loss
qualityWearShield: 0.7, // higher quality slows wear (0..1 fraction of wear removed at quality=1)
// --- STOCHASTIC FAILURE (the BATHTUB) ------------------------------------------------------------
hInfant0: 0.06, // infant-mortality hazard scale (per day) at birth for a defective unit
infantTauDays: 12, // how fast infant mortality decays away (e-folding, sim-days)
hConst: 0.0016, // the flat random middle-of-life hazard (per day) -- bad luck, independent of age
hWearK: 3.2, // wear-out hazard scale -- multiplies a rising function of wear+condition
wearoutPow: 3.0, // steepness of the wear-out climb (Weibull-like shape)
condFailBoost: 2.2, // a nearly-worn-out (low-condition) unit fails much more readily
// --- MAINTENANCE / REPAIR ------------------------------------------------------------------------
maintain: true, // the maintenance rung on/off (a clean toggle for tests)
repairThresh: 0.45, // condition below which we consider a repair
repairAmount: 0.85, // fraction of the gap up to the restore-ceiling a repair recovers
repairCeil0: 1.0, // the ceiling condition a FIRST repair can reach
repairCeilDecay: 0.12, // each repair lowers that ceiling (DIMINISHING RETURNS -- never good as new)
repairCostFrac: 0.35, // cost of a repair as a fraction of the cost to build new (used for repair-vs-replace)
repairFatigueRelief: 0.15,// a repair sheds only a little accumulated fatigue (wear is mostly permanent)
repairDailyP: 0.5, // chance/day a due, worth-repairing unit actually gets serviced (shop capacity)
retireCondFloor: 0.12, // below this restore-ceiling, repair is pointless -> retire (scrap) instead
// --- BROKEN handling + RECYCLE -------------------------------------------------------------------
salvageDailyP: 0.4, // chance/day a BROKEN unit is assessed: repaired if worth it, else scrapped
breakdownRepairable: 0.55,// fraction of failures that are repairable at all (rest are write-offs)
recycleRate: 0.5, // fraction of the scrap pool reclaimed to raw per day
recycleLoss: 0.08, // fraction LOST to entropy on each recycle pass (mass leaves the usable loop)
replenish: 0.0 // fresh raw added per day (mining) -- 0 = a truly closed world
}, config.params || {});
// sample a truncated-normal-ish grade from two uniforms (Box-Muller, clamped)
function gradeDraw(rng) {
const u1 = clamp(rng(), 1e-9, 1), u2 = rng();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return clamp(P.gradeMean + P.gradeSD * z, 0.02, 0.99);
}
// the per-day HAZARD of an artifact -- the sum of three regimes = the bathtub curve.
function hazardPerDay(a, ageDays) {
// 1. INFANT MORTALITY: high at birth for a defective/weak unit, decays away exponentially. A sound unit
// (defect 0) contributes almost nothing here; a defective one dominates its own early life.
const defect = a.defect; // 0 (sound) .. ~1+ (badly flawed)
const infant = P.hInfant0 * defect * Math.exp(-ageDays / P.infantTauDays);
// 2. the CONSTANT random hazard -- bad luck that does not care how old you are.
const constant = P.hConst;
// 3. WEAR-OUT: rises steeply as accumulated wear climbs and condition falls. Weibull-shaped in wear, with
// a low-condition multiplier so a nearly-spent unit is on borrowed time. Higher quality delays it.
const w = clamp(a.wear, 0, 4);
const condPenalty = 1 + P.condFailBoost * (1 - clamp(a.condition, 0, 1));
const qShield = 1 - P.qualityWearShield * a.quality * 0.5; // quality also slows the wear-out clock a little
const wearout = P.hWearK * Math.pow(w, P.wearoutPow) * condPenalty * qShield * 1e-3;
return infant + constant + wearout;
}
return {
name: config.name || 'artifact',
domain: 'artifice',
scale: 'material',
params: config,
init(rng, params, clock) {
const st = {
cap,
// per-artifact arrays (structure-of-arrays, like lifecycle)
state: new Uint8Array(cap),
quality: new Float32Array(cap), // material grade at birth (fixed) -- slows wear + wear-out
age: new Float32Array(cap), // sim-days since manufacture
condition: new Float32Array(cap), // 0..1 serviceable health (repairable)
wear: new Float32Array(cap), // accumulated irreversible fatigue (0..~4)
defect: new Float32Array(cap), // latent birth defect severity (drives infant mortality)
repairs: new Uint16Array(cap), // how many times mended
repairCeil: new Float32Array(cap), // the ceiling a repair can still reach (falls each repair)
__rep: new Uint8Array(cap), // is a currently-BROKEN unit repairable at all (1/0)
bornAt: new Float32Array(cap), // clock time of manufacture
// --- the finite material ledger ---
rawStock: P.rawStock0,
scrapStock: P.scrapStock0,
entropyLost: 0, // matter permanently lost to recycle entropy (bookkeeping)
replenished: 0, // matter added by mining (bookkeeping)
// --- census + flow counters ---
nWorking: 0, nBroken: 0, nScrap: 0, nEmpty: cap,
built: 0, failed: 0, repaired: 0, retired: 0, recycledMass: 0,
fInfant: 0, fMid: 0, fWearout: 0, // failures classified by regime (for the bathtub)
meanAge: 0, meanCondition: 0, meanWear: 0, meanQuality: 0,
buildBacklog: 0, // fractional builds carried between steps
evs: []
};
// no artifacts at t0 -- the shop starts cold and fills the world from raw. (A warm start could seed
// some; a cold start makes the material accounting unambiguous and lets a cohort be watched from birth.)
return st;
},
step(st, ctx, dt) {
const dd = dt / 86400; if (dd <= 0) return st; // step length in sim-days
const s = st; s.evs.length = 0;
const rng = ctx && ctx.rng ? ctx.rng : Math.random;
const clock = ctx && ctx.clock ? ctx.clock : null;
const now = clock ? clock.t : (s._t = (s._t || 0) + dt);
// ---- coupled LABOUR supply (optional): another system can throttle the shop. Absent -> full supply.
const lab = ctx && ctx.fields ? ctx.fields[labourName] : null;
const labourSupply = lab && typeof lab.supply === 'number' ? clamp(lab.supply, 0, 1) : 1;
// =====================================================================================================
// 1. MANUFACTURE -- raw material + labour -> artifacts. Gated by raw on hand, by the coupled labour
// supply, and (if targetStock>0) by demand: build only while the working fleet is short. Each new
// unit draws a material QUALITY from the stock grade and, with defectRate, a latent BIRTH DEFECT.
// =====================================================================================================
let want = P.buildRate * dd * labourSupply;
if (P.targetStock > 0) { const deficit = P.targetStock - s.nWorking; want = deficit <= 0 ? 0 : Math.min(want, deficit); }
s.buildBacklog += want;
let toBuild = Math.floor(s.buildBacklog);
s.buildBacklog -= toBuild;
while (toBuild > 0) {
if (s.rawStock < P.massPerUnit) break; // out of matter -> the line stops
// find a free slot
let slot = -1; for (let j = 0; j < cap; j++) if (s.state[j] === EMPTY) { slot = j; break; }
if (slot < 0) break; // world full
s.rawStock -= P.massPerUnit; // matter leaves raw, becomes product (conserved)
const q = gradeDraw(rng);
const isDefective = rng() < P.defectRate;
const defect = isDefective ? P.defectSeverity * (0.4 + 0.9 * rng()) : 0.03 * rng(); // sound units carry a whisper of defect, not zero
s.state[slot] = WORKING;
s.quality[slot] = q;
s.age[slot] = 0;
s.condition[slot] = 1.0;
s.wear[slot] = 0;
s.defect[slot] = defect;
s.repairs[slot] = 0;
s.repairCeil[slot] = P.repairCeil0;
s.bornAt[slot] = now;
s.built++;
s.evs.push({ kind: 'manufactured', id: slot, quality: q, defect });
toBuild--;
}
// =====================================================================================================
// per-artifact pass: USE+WEAR, stochastic FAILURE (bathtub), MAINTENANCE, and BROKEN-unit disposition.
// =====================================================================================================
let nW = 0, nB = 0, nSc = 0, nE = 0;
let ageSum = 0, condSum = 0, wearSum = 0, qSum = 0, aliveN = 0;
for (let i = 0; i < cap; i++) {
const stt = s.state[i];
if (stt === EMPTY) { nE++; continue; }
if (stt === SCRAP) {
// ---- 5. RECYCLE (per unit): a scrap-yard item is reclaimed with a per-day probability. Its matter
// returns toward RAW, but a fraction is LOST to entropy forever. Then the slot frees.
if (rng() < clamp(P.recycleRate * dd, 0, 1)) {
const lost = P.massPerUnit * P.recycleLoss;
s.rawStock += P.massPerUnit - lost; // most of the body's matter returns to raw...
s.entropyLost += lost; // ...a sliver is gone for good (the second law)
s.recycledMass += P.massPerUnit - lost;
s.evs.push({ kind: 'recycled', id: i, lost });
// free the slot
s.state[i] = EMPTY;
s.age[i] = 0; s.condition[i] = 0; s.wear[i] = 0; s.defect[i] = 0; s.repairs[i] = 0; s.repairCeil[i] = 0; s.__rep[i] = 0;
nE++;
} else {
nSc++; // still sitting in the yard
}
continue;
}
if (stt === WORKING) {
s.age[i] += dd;
// ---- 2. USE + WEAR: condition falls with duty * stress, shielded by quality; fatigue accrues.
const qShield = 1 - P.qualityWearShield * s.quality[i]; // high grade wears slower
const dCond = P.wearRate * P.useRate * P.stress * qShield * dd;
s.condition[i] = clamp(s.condition[i] - dCond, 0, 1);
s.wear[i] += P.fatiguePerWear * dCond; // fatigue is irreversible accumulation
// ---- 3. STOCHASTIC FAILURE on the bathtub hazard ----
const ageDays = s.age[i];
const hz = hazardPerDay({ defect: s.defect[i], wear: s.wear[i], condition: s.condition[i], quality: s.quality[i] }, ageDays);
const pFail = 1 - Math.exp(-hz * dd);
if (rng() < pFail) {
// classify the failure by which regime dominated -- this is what the bathtub test reads. Split the
// hazard back into its three additive parts and attribute the failure to the largest.
const infantPart = P.hInfant0 * s.defect[i] * Math.exp(-ageDays / P.infantTauDays);
const wearPart = Math.max(0, hz - infantPart - P.hConst);
let regime;
if (infantPart >= wearPart && infantPart >= P.hConst) regime = 'infant';
else if (wearPart >= infantPart && wearPart >= P.hConst) regime = 'wearout';
else regime = 'mid';
if (regime === 'infant') s.fInfant++; else if (regime === 'wearout') s.fWearout++; else s.fMid++;
s.failed++;
// a failure is either repairable or a write-off; record which so the salvage decision is exact
const repairable = rng() < P.breakdownRepairable;
s.state[i] = BROKEN;
s.condition[i] = clamp(s.condition[i] * 0.3, 0, 1); // a break slumps condition
s.__rep[i] = repairable ? 1 : 0;
s.evs.push({ kind: 'failure', id: i, regime, age: ageDays, wear: s.wear[i], repairable });
nB++;
continue;
}
// ---- 4. MAINTENANCE: a WORKING unit whose condition has fallen below threshold may be serviced
// preventively. Diminishing returns: each repair restores toward a ceiling that itself falls,
// and once that ceiling drops below a floor the unit is retired instead of endlessly patched.
if (P.maintain && s.condition[i] < P.repairThresh) {
if (s.repairCeil[i] < P.retireCondFloor) {
// not worth mending any more -> retire to scrap (the repair-vs-replace verdict)
retire(s, i, 'worn-out');
nSc++;
continue;
}
if (rng() < clamp(P.repairDailyP * dd, 0, 1)) { // a due unit is serviced with a per-day probability (shop capacity)
doRepair(s, i);
s.evs.push({ kind: 'maintained', id: i, repairs: s.repairs[i], ceil: s.repairCeil[i] });
}
}
nW++; aliveN++; ageSum += s.age[i]; condSum += s.condition[i]; wearSum += s.wear[i]; qSum += s.quality[i];
continue;
}
if (stt === BROKEN) {
// ---- BROKEN units await a salvage decision: repair if repairable AND worth it, else scrap ----
if (rng() < clamp(P.salvageDailyP * dd, 0, 1)) {
const rep = s.__rep[i];
// repair-vs-replace: mend only if repairable, the restore ceiling is still above the floor, and
// the projected repaired value (ceiling) beats the cost fraction of building fresh.
const worth = rep && s.repairCeil[i] > P.retireCondFloor && s.repairCeil[i] > P.repairCostFrac;
if (P.maintain && worth) {
s.state[i] = WORKING;
doRepair(s, i);
s.evs.push({ kind: 'salvaged', id: i, repairs: s.repairs[i], ceil: s.repairCeil[i] });
nW++; aliveN++; ageSum += s.age[i]; condSum += s.condition[i]; wearSum += s.wear[i]; qSum += s.quality[i];
} else {
retire(s, i, rep ? 'not-worth-repair' : 'write-off');
nSc++;
}
} else {
nB++; // still sitting broken, awaiting the assessment
}
continue;
}
}
// ---- an optional BULK scrap pool (seeded via scrapStock0, or fed by an external system) recycles the
// same way -- most back to raw, a fraction lost to entropy. Zero by default (the per-unit scrap yard
// above carries the closed loop); kept so a coupled economy can pour loose scrap in.
if (s.scrapStock > 1e-9) {
const reclaimed = s.scrapStock * clamp(P.recycleRate * dd, 0, 1);
const lost = reclaimed * P.recycleLoss;
s.rawStock += reclaimed - lost;
s.entropyLost += lost;
s.scrapStock -= reclaimed;
s.recycledMass += reclaimed - lost;
}
// fresh raw mined in (0 = a truly closed world, where usable mass can only decay)
if (P.replenish > 0) { const add = P.replenish * dd; s.rawStock += add; s.replenished += add; }
// census + means
s.nWorking = nW; s.nBroken = nB; s.nScrap = nSc; s.nEmpty = nE;
s.meanAge = aliveN ? ageSum / aliveN : 0;
s.meanCondition = aliveN ? condSum / aliveN : 0;
s.meanWear = aliveN ? wearSum / aliveN : 0;
s.meanQuality = aliveN ? qSum / aliveN : 0;
return s;
// ---- helpers (closures over s/P) ----
function doRepair(s, i) {
const ceil = s.repairCeil[i];
const gap = ceil - s.condition[i];
s.condition[i] = clamp(s.condition[i] + P.repairAmount * gap, 0, ceil); // restore toward the ceiling
s.wear[i] = Math.max(0, s.wear[i] - P.repairFatigueRelief); // sheds a little fatigue only
s.repairCeil[i] = Math.max(0, ceil - P.repairCeilDecay); // the ceiling falls: diminishing returns
s.repairs[i] += 1;
s.repaired++;
}
function retire(s, i, why) {
// the artifact becomes SCRAP occupying a slot in the scrap yard, still HOLDING its matter (mass is
// conserved -- it moves from product to scrap only when the recycler reclaims it below). This gives a
// genuine three-state population (working / broken / scrap) rather than a bookkeeping pool.
s.state[i] = SCRAP;
s.__rep[i] = 0;
s.retired++;
s.evs.push({ kind: 'retired', id: i, why, age: s.age[i], repairs: s.repairs[i] });
}
},
fields(st) {
return {
cap: st.cap,
// material ledger (the closed loop). Every unit of matter is in exactly one place at any instant:
// raw stock, the bulk scrap pool, locked inside a product (working/broken), sitting in the scrap yard
// (a SCRAP unit still holds its matter until reclaimed), or lost to entropy. replenished is mined-in.
rawStock: st.rawStock, scrapStock: st.scrapStock, entropyLost: st.entropyLost, replenished: st.replenished,
// mass locked in physical bodies right now: working + broken (live product) + scrap-yard units
productMass: (st.nWorking + st.nBroken) * P.massPerUnit,
yardMass: st.nScrap * P.massPerUnit,
// CONSERVATION invariant: this should hold constant at rawStock0 + scrapStock0 for a closed world
// (replenish 0), and grow by exactly `replenished` otherwise. usableMass omits what entropy ate.
totalMass: st.rawStock + st.scrapStock + (st.nWorking + st.nBroken + st.nScrap) * P.massPerUnit + st.entropyLost - st.replenished,
usableMass: st.rawStock + st.scrapStock + (st.nWorking + st.nBroken + st.nScrap) * P.massPerUnit,
// census
nWorking: st.nWorking, nBroken: st.nBroken, nScrap: st.nScrap, nEmpty: st.nEmpty,
// flow counters
built: st.built, failed: st.failed, repaired: st.repaired, retired: st.retired,
fInfant: st.fInfant, fMid: st.fMid, fWearout: st.fWearout,
recycledMass: st.recycledMass,
// means / reliability observables
meanAge: st.meanAge, meanCondition: st.meanCondition, meanWear: st.meanWear, meanQuality: st.meanQuality,
// raw arrays for a viz / deeper tests
state: st.state, age: st.age, condition: st.condition, wear: st.wear, quality: st.quality, repairs: st.repairs,
massPerUnit: P.massPerUnit,
STATE: { EMPTY, WORKING, BROKEN, SCRAP }
};
},
events(st) { return st.evs; },
// exposed for tests that want to drive a single cohort's hazard directly
_hazardPerDay: hazardPerDay,
_P: P
};
}
return { makeArtifact, STATE: { EMPTY, WORKING, BROKEN, SCRAP } };
});