← Natura·Docs/Modules/Source

technology.js

345 lines · in natura-src-1.0.0.zip as natura/technology.js · a module · its entry in the module reference · its test: technology-test.js

// technology.js -- Natura KNOWLEDGE / TECHNOLOGY (the ontology's CULTURE rung seen through what a people can
// DO, not what they feel). Where SOCIETY spreads a MOOD, this spreads INFORMATION WITH CONTENT: techniques
// that have prerequisites, serve functions, and RECOMBINE. A technique is not a scalar "tech level"; it is a
// discrete id sitting in a hidden TECH-TREE, and a population is a set of agents each HOLDING a set of those
// ids. Knowledge here is a thing that can be invented, taught (imperfectly), bettered, made obsolete, and --
// the uncanny pole this rung exists to show -- LOST. A technique known to too few, whose few holders turn over
// before they pass it on, VANISHES from the world; a people can fall into a dark age and have to re-invent
// what it once knew. Consensus without truth was SOCIETY's ghost; a DARK AGE is this rung's.
//
// The engine of growth is COMBINATORIAL: a new technique is formed by combining >=2 techniques already known,
// and the more a population knows the more pairs it can try, so invention ACCELERATES on itself (cumulative
// culture -- the ratchet). But the same combinatorics needs VARIETY: recombination of a monoculture (everyone
// knowing the same few things) finds little; a DIVERSE population, its knowledge spread across many corners,
// invents faster. And a hidden RECIPE map + PREREQUISITE gates give the tree its shape -- some combos unlock a
// real new tech (gated by lower techs already held), most combine to nothing -- so the growth is structured,
// not a free-for-all, and higher techs genuinely stand on lower ones.
//
// Five coupled mechanisms, none meaningful alone: (1) INVENTION by recombination against a hidden recipe tree;
// (2) DIFFUSION with FIDELITY < 1 (a lesson can fail or garble); (3) OBSOLESCENCE -- a better technique for a
// niche displaces a worse one in USE; (4) LOSS -- turnover + rarity can erase a technique from the whole
// population; (5) CUMULATIVE prerequisites -- a high tech needs the lower techs it is built on. Deterministic
// (rng only from init's seed + ctx.rng; time only from dt/clock), node-testable. UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Technology = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
  const keyOf = (a, b) => a < b ? (a + ',' + b) : (b + ',' + a);   // an unordered pair of tech ids -> a recipe key

  // A small deterministic rng we can seed independently of the world stream, so the HIDDEN TECH-TREE (built at
  // init) is a fixed property of the config seed and does NOT consume the per-step world rng. mulberry32, same
  // family as natura.js, kept local so this module stands alone.
  function rngFrom(seed) {
    let a = seed >>> 0;
    return function () {
      a = (a + 0x6D2B79F5) | 0;
      let t = Math.imul(a ^ (a >>> 15), 1 | a);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  }

  // ---- the hidden TECH-TREE: the "physics of invention" -------------------------------------------------
  // Built once, deterministically from techSeed. Nodes are laid out in TIERS. Tier-0 techs are PRIMITIVES a
  // mind can stumble on unaided (fire, stone, fibre...). Every higher tech is the child of a RECIPE: an
  // unordered pair of lower techs whose combination invents it, gated by a set of PREREQUISITES (lower techs
  // that must ALSO be known to use/build it -- the cumulative floor). Each tech serves a NICHE (a function --
  // cutting, heating, carrying...) with a UTILITY in that niche, and higher tiers tend to serve their niche
  // BETTER, which is what lets a later tech make an earlier one OBSOLETE. The map from recipe-key -> child is
  // the hidden structure; most pairs map to nothing, so invention is a search over a sparse tree.
  function buildTree(cfg) {
    const rng = rngFrom(cfg.techSeed >>> 0);
    const tiers = cfg.tiers, perTier = cfg.perTier, niches = cfg.niches;
    const techs = [];                 // techs[id] = { id, tier, niche, utility, prereqs:[], recipe:[a,b]|null }
    const byTier = [];
    for (let ti = 0; ti < tiers; ti++) byTier.push([]);
    let id = 0;
    // tier 0: primitives -- no recipe, no prereqs; a spread of niches so the base is varied
    for (let k = 0; k < perTier; k++) {
      const niche = k % niches;
      techs.push({ id, tier: 0, niche, utility: 0.15 + 0.10 * rng(), prereqs: [], recipe: null });
      byTier[0].push(id); id++;
    }
    const recipeMap = new Map();       // recipe-key "a,b" -> child id (the hidden combination table)
    // higher tiers: each new tech is the child of a pair drawn from LOWER tiers, plus prereqs from below.
    for (let ti = 1; ti < tiers; ti++) {
      for (let k = 0; k < perTier; k++) {
        const niche = (ti + k) % niches;
        // pick two DISTINCT parents from any lower tier (recombination reaches back down the tree)
        const lowerPool = [];
        for (let lt = 0; lt < ti; lt++) for (const p of byTier[lt]) lowerPool.push(p);
        let a = lowerPool[Math.floor(rng() * lowerPool.length)];
        let b = lowerPool[Math.floor(rng() * lowerPool.length)];
        let guard = 0;
        while (b === a && guard++ < 16) b = lowerPool[Math.floor(rng() * lowerPool.length)];
        if (b === a) continue;         // degenerate pool (shouldn't happen with perTier>=2)
        const rkey = keyOf(a, b);
        if (recipeMap.has(rkey)) continue;   // that pair is already spoken for -- keep recipes unique
        // PREREQUISITES: the two parents, plus (sometimes) one extra lower tech -- the cumulative floor. A
        // higher tier tends to demand more, so late techs genuinely stand on a tall stack.
        const prereqs = new Set([a, b]);
        if (ti >= 2 && rng() < 0.6) prereqs.add(lowerPool[Math.floor(rng() * lowerPool.length)]);
        if (ti >= 3 && rng() < 0.5) prereqs.add(lowerPool[Math.floor(rng() * lowerPool.length)]);
        // UTILITY climbs with tier (with noise) so later techs can obsolete earlier ones in the same niche
        const utility = clamp(0.25 + 0.14 * ti + 0.10 * (rng() - 0.5), 0, 1);
        const child = { id, tier: ti, niche, utility, prereqs: Array.from(prereqs), recipe: [a, b] };
        techs.push(child);
        recipeMap.set(rkey, id);
        byTier[ti].push(id);
        id++;
      }
    }
    return { techs, recipeMap, byTier, count: id };
  }

  // make a Technology system. config (all optional):
  //   population, tiers, perTier, niches, techSeed,
  //   fidelity (diffusion success prob), inventRate (recombination attempts / agent / day),
  //   diffuseRate (learning attempts / agent / day), meanContacts (network degree),
  //   turnover (fraction of agents replaced per day), inherit (fraction of parent knowledge a newborn keeps),
  //   seedTechs (how many tier-0 primitives each agent starts knowing), obsolescence (bool toggle).
  function makeTechnology(config) {
    config = config || {};
    const P = config.population || 80;
    const cfg = {
      tiers: config.tiers || 6,
      perTier: config.perTier || 6,
      niches: config.niches || 5,
      techSeed: config.techSeed != null ? config.techSeed : 12345
    };
    const fidelity = config.fidelity != null ? config.fidelity : 0.85;      // a lesson succeeds this often (transmission < 1)
    const inventRate = config.inventRate != null ? config.inventRate : 3.0; // recombination ATTEMPTS per agent per sim-day
    const diffuseRate = config.diffuseRate != null ? config.diffuseRate : 4.0; // learning attempts per agent per sim-day
    const meanContacts = config.meanContacts != null ? config.meanContacts : 8; // network degree (connectivity)
    const turnover = config.turnover != null ? config.turnover : 0.0;       // fraction of the population replaced per sim-day (death/birth)
    const inherit = config.inherit != null ? config.inherit : 0.5;          // fraction of a parent's knowledge a newborn keeps
    const seedTechs = config.seedTechs != null ? config.seedTechs : 3;      // primitives each agent is born knowing at t0
    const obsolescence = config.obsolescence != null ? config.obsolescence : true;

    const tree = buildTree(cfg);
    const T = tree.count;

    // does agent a (a Set of known ids) satisfy tech `id`'s prerequisites? (the cumulative floor)
    function prereqsMet(known, id) {
      const pr = tree.techs[id].prereqs;
      for (let i = 0; i < pr.length; i++) if (!known.has(pr[i])) return false;
      return true;
    }

    return {
      name: config.name || 'technology',
      domain: 'culture',
      scale: 'population',
      params: config,

      init(rng, params, clock) {
        const _rng = rng || rngFrom((config.seed || 1) >>> 0);
        const agents = [];
        for (let a = 0; a < P; a++) {
          const known = new Set();
          // each agent is born knowing a few PRIMITIVES (tier-0), drawn with variety so the starting
          // population is DIVERSE across corners of the base -- the raw material recombination feeds on.
          for (let s = 0; s < seedTechs; s++) {
            const t0 = tree.byTier[0][Math.floor(_rng() * tree.byTier[0].length)];
            known.add(t0);
          }
          agents.push({ known, use: new Int32Array(cfg.niches).fill(-1), age: _rng() * 1.0, alive: true });
        }
        // fixed random contact network (undirected), degree ~ meanContacts. Connectivity is a lever the tests
        // vary: a well-connected people RETAINS knowledge a fragmented one loses.
        const contacts = [];
        for (let a = 0; a < P; a++) contacts.push([]);
        const targetEdges = Math.round(P * meanContacts / 2);
        for (let e = 0; e < targetEdges; e++) {
          const u = Math.floor(_rng() * P); let v = Math.floor(_rng() * P);
          let guard = 0; while (v === u && guard++ < 8) v = Math.floor(_rng() * P);
          if (v === u) continue;
          if (contacts[u].indexOf(v) === -1) { contacts[u].push(v); contacts[v].push(u); }
        }
        const st = {
          agents, contacts, T, niches: cfg.niches,
          _techs: tree.techs,                 // the hidden tech-tree node table (recompute reads niche/utility/tier)
          // observables (filled by recompute)
          knownCount: 0, meanHeld: 0, maxTier: 0, diversity: 0, holders: new Int32Array(T),
          invented: 0, lost: 0, diffusions: 0, inUseUtility: 0,
          everInvented: new Uint8Array(T),   // has this tech EVER existed in the population (for detecting LOSS)
          _prevHolders: new Int32Array(T), _prevMaxTier: 0,
          _newInventions: [], _lostThisStep: [], evs: [],
          _acc: 0, _dacc: 0, _tacc: 0,        // fractional accumulators for sub-integer rates
          steps: 0
        };
        // seed everInvented with the primitives the population starts holding
        recompute(st);
        for (let id = 0; id < T; id++) if (st.holders[id] > 0) st.everInvented[id] = 1;
        return st;
      },

      step(st, ctx, dt) {
        const rng = ctx && ctx.rng ? ctx.rng : Math.random;
        const dd = clamp((dt || 0) / 86400, 0, 3650);   // step length in sim-days (bounded)
        const agents = st.agents, contacts = st.contacts;
        st.evs.length = 0; st._newInventions.length = 0; st._lostThisStep.length = 0;
        st.steps++;

        // ---- (4) LOSS / TURNOVER: age the population and replace a fraction. A replaced agent is a DEATH: its
        // private knowledge dies with it UNLESS a newborn inherits a fraction, or the tech survives in someone
        // else. A tech held by too FEW whose holders all turn over before teaching it VANISHES -- the dark-age
        // mechanism. Turnover uses a fractional accumulator so sub-one-per-step rates integrate correctly.
        if (turnover > 0 && P > 0) {
          st._tacc += turnover * P * dd;
          let nReplace = Math.floor(st._tacc);
          st._tacc -= nReplace;
          nReplace = Math.min(nReplace, P);
          for (let r = 0; r < nReplace; r++) {
            const victim = Math.floor(rng() * P);
            // the newborn inherits a fraction of the victim's knowledge (parent-to-child teaching), the rest is
            // lost with the dead. inherit=0 => a clean slate (harshest loss); inherit=1 => nothing is lost.
            const oldKnown = agents[victim].known;
            const child = new Set();
            oldKnown.forEach(id => { if (rng() < inherit) child.add(id); });
            agents[victim] = { known: child, use: new Int32Array(cfg.niches).fill(-1), age: 0, alive: true };
          }
        }

        // ---- (2) DIFFUSION: each agent tries to learn a technique from a random CONTACT who knows something it
        // doesn't. Transmission has FIDELITY < 1 -- the lesson can simply fail (garble), so knowledge does not
        // spread frictionlessly. This is what a well-connected population does that a fragmented one cannot:
        // move a rare technique from its few holders to many before those holders turn over.
        {
          st._dacc += diffuseRate * P * dd;
          let nLearn = Math.floor(st._dacc); st._dacc -= nLearn;
          nLearn = Math.min(nLearn, P * 40);
          for (let l = 0; l < nLearn; l++) {
            const a = Math.floor(rng() * P), nbrs = contacts[a];
            if (!nbrs.length) continue;
            const b = nbrs[Math.floor(rng() * nbrs.length)];
            const kb = agents[b].known, ka = agents[a].known;
            if (kb.size === 0) continue;
            // pick a technique the teacher knows and the learner lacks
            let pick = -1, seen = 0;
            kb.forEach(id => { if (!ka.has(id)) { seen++; if (rng() < 1 / seen) pick = id; } });   // reservoir pick
            if (pick < 0) continue;
            // to LEARN a tech you must already hold its prerequisites (you cannot copy a lesson you have no
            // floor for) -- diffusion respects the cumulative structure too.
            if (!prereqsMet(ka, pick)) continue;
            if (rng() < fidelity) { ka.add(pick); st.diffusions++; }   // else the lesson failed -- fidelity < 1
          }
        }

        // ---- (1) INVENTION by RECOMBINATION: each agent tries to combine >=2 techniques it KNOWS. If that
        // unordered pair matches a hidden recipe AND the agent holds the child's prerequisites, a NEW technique
        // is invented (new to that agent; new to the WORLD if never invented before). The number of pairs grows
        // as O(k^2) in the agent's known-count k, so a knowledgeable, VARIED population invents combinatorially
        // faster -- the ratchet. A monoculture (everyone knowing the same few) has few novel pairs and stalls.
        {
          st._acc += inventRate * P * dd;
          let nTry = Math.floor(st._acc); st._acc -= nTry;
          nTry = Math.min(nTry, P * 40);
          for (let it = 0; it < nTry; it++) {
            const a = Math.floor(rng() * P), ka = agents[a].known;
            if (ka.size < 2) continue;
            const arr = Array.from(ka);
            const i1 = Math.floor(rng() * arr.length);
            let i2 = Math.floor(rng() * arr.length);
            let g = 0; while (i2 === i1 && g++ < 8) i2 = Math.floor(rng() * arr.length);
            if (i2 === i1) continue;
            const child = tree.recipeMap.get(keyOf(arr[i1], arr[i2]));
            if (child == null) continue;             // that combination invents nothing (most don't)
            if (ka.has(child)) continue;             // already known
            if (!prereqsMet(ka, child)) continue;    // the cumulative floor isn't met yet
            ka.add(child);
            st.invented++;
            if (!st.everInvented[child]) { st.everInvented[child] = 1; st._newInventions.push(child); }
          }
        }

        recompute(st);

        // ---- (4b) detect LOSS at the population scale: a tech that was EVER invented but is now held by NOBODY
        // has been lost from the world (a dark age for that technique). Recorded as a running counter + event.
        for (let id = 0; id < st.T; id++) {
          if (st.everInvented[id] && st.holders[id] === 0) {
            // count it once at the moment it drops to zero this step
            if (st._prevHolders && st._prevHolders[id] > 0) { st.lost++; st._lostThisStep.push(id); }
          }
        }
        st._prevHolders = st.holders.slice();

        // ---- events -----------------------------------------------------------------------------------------
        for (const id of st._newInventions) st.evs.push({ kind: 'invention', tech: id, tier: tree.techs[id].tier });
        for (const id of st._lostThisStep) st.evs.push({ kind: 'loss', tech: id, tier: tree.techs[id].tier });
        // a BREAKTHROUGH: the population reaches a new maximum tier for the first time
        if (st.maxTier > (st._prevMaxTier || 0)) { st.evs.push({ kind: 'breakthrough', tier: st.maxTier }); st._prevMaxTier = st.maxTier; }
        return st;
      },

      fields(st) {
        return {
          knownCount: st.knownCount,     // distinct techniques ALIVE in the population right now
          meanHeld: st.meanHeld,         // mean techniques per agent
          maxTier: st.maxTier,           // deepest tier reached (cumulative depth)
          diversity: st.diversity,       // spread of knowledge across agents (recombination fuel)
          invented: st.invented,         // cumulative invention events
          lost: st.lost,                 // cumulative techniques lost from the whole population (dark ages)
          diffusions: st.diffusions,     // cumulative successful teachings
          inUseUtility: st.inUseUtility, // mean utility of the best-in-niche tech agents actually USE (obsolescence)
          holders: st.holders,           // per-tech holder counts
          T: st.T
        };
      },

      events(st) { return st.evs; },

      // exposed for tests / introspection (not part of the coupling contract)
      _tree: tree
    };

    // ---- observables recompute (module-scoped so init + step share it). Also runs OBSOLESCENCE. -------------
    function recompute(st) {
      const agents = st.agents, T = st.T, niches = st.niches;
      const holders = st.holders; holders.fill(0);
      let sumHeld = 0;
      for (let a = 0; a < agents.length; a++) {
        const ka = agents[a].known;
        sumHeld += ka.size;
        ka.forEach(id => { holders[id]++; });
        // ---- (3) OBSOLESCENCE: for each niche, the agent USES the highest-UTILITY tech it knows, so a better
        // later technique displaces a worse earlier one in actual use. (5) prereqs are already enforced at
        // learn/invent time, so anything an agent holds is usable.
        if (obsolescence) {
          const use = agents[a].use; use.fill(-1);
          const bestU = new Float32Array(niches); bestU.fill(-1);
          ka.forEach(id => {
            const tk = st._techs[id]; const n = tk.niche;
            if (tk.utility > bestU[n]) { bestU[n] = tk.utility; use[n] = id; }
          });
        }
      }
      // distinct techs alive + max tier
      let known = 0, maxTier = 0;
      for (let id = 0; id < T; id++) if (holders[id] > 0) { known++; const tt = st._techs[id].tier; if (tt > maxTier) maxTier = tt; }
      st.knownCount = known;
      st.meanHeld = agents.length ? sumHeld / agents.length : 0;
      st.maxTier = maxTier;
      // DIVERSITY: normalized Shannon entropy of the holder distribution over ALIVE techs -- high when knowledge
      // is spread across many different techniques (varied), low when it collapses onto a few (a monoculture).
      let totH = 0; for (let id = 0; id < T; id++) totH += holders[id];
      let H = 0, aliveN = 0;
      if (totH > 0) {
        for (let id = 0; id < T; id++) { const h = holders[id]; if (h > 0) { aliveN++; const p = h / totH; H -= p * Math.log(p); } }
      }
      st.diversity = aliveN > 1 ? H / Math.log(aliveN) : 0;
      // mean in-use utility across agents+niches (rises as obsolescence upgrades what people actually use)
      let uSum = 0, uN = 0;
      if (obsolescence) {
        for (let a = 0; a < agents.length; a++) { const use = agents[a].use; for (let n = 0; n < niches; n++) { if (use[n] >= 0) { uSum += st._techs[use[n]].utility; uN++; } } }
      }
      st.inUseUtility = uN ? uSum / uN : 0;
    }
  }

  return { makeTechnology };
});