← Natura·Docs/Modules/Source

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