← Natura·Docs/Modules/Source

society.js

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

// society.js -- Natura SOCIETY (the ontology's SOCIETY rung, previously untouched; SYSTEMS.md). SOCIETY is
// emergent OVER many minds: it reads the MIND system beneath it and produces the collective. This first
// SOCIETY system is the ATMOSPHERE + its CONTAGION. It reads where the minds are and what they feel, and
// forms a spatial MOOD FIELD -- the fear and hope in the air over a crowd -- which the minds in turn breathe
// back in (a mind conforms toward the feeling around it). Out of that loop comes the SOCIETY-scale uncanny:
// a false belief does not just persist in one mind, it SPREADS, and a whole population can come to share a
// conviction that answers to nothing in the world -- a rumour, a panic, a myth -- held up by everyone
// confirming everyone else. Consensus without truth. The MIND rung showed the map diverging from the
// territory; the SOCIETY rung shows a shared map diverging together, which is harder to break because each
// mind's error is propped by its neighbours'.
//
// Reads ctx.fields.<mind> (positions + fear/hope/beliefs/arousal). EXPOSES fearField/hopeField (the mood a
// mind reads back via its `conform` coupling) + collective observables (meanFear/meanHope, CONSENSUS, the
// dominant feeling) + events (panic, rally, schism). Deterministic + node-testable. UMD.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Society = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;

  function makeSociety(config) {
    config = config || {};
    const N = config.N || 16;                          // mood-field resolution -- coarse; an atmosphere, not a pixel map
    const mindName = config.mind || 'mind';            // which MIND system to read
    const blurIters = config.blur != null ? config.blur : 2;
    const reach = config.reach != null ? config.reach : 1.0;   // how far a mind's feeling carries into the air (splat radius, in cells)
    // COLLECTIVE MEMORY (the CULTURE rung) -- the society remembers its own past feeling and lets it colour the present.
    const memory = config.memory != null ? config.memory : true;                     // the memory rung on/off (a clean toggle for tests)
    const scarGain = config.scarGain != null ? config.scarGain : 0.5;                // how fast SUSTAINED collective fear scars the culture (per sim-day)
    const legacyGain = config.legacyGain != null ? config.legacyGain : 0.5;          // how fast SUSTAINED collective hope builds a legacy (per sim-day)
    const memThresh = config.memThresh != null ? config.memThresh : 0.4;             // collective feeling must exceed this to leave a mark (a passing mood does not scar)
    const memHalfDays = config.memHalfDays != null ? config.memHalfDays : 25;        // half-life of the memory in sim-days (a culture forgets slowly, so history outlives the event)
    const memBias = config.memBias != null ? config.memBias : 0.25;                  // how strongly the remembered temper colours the air the minds breathe
    // HIERARCHY (the pecking-order rung) -- not everyone shapes the mood equally; the heeded are heeded more.
    const hierarchy = config.hierarchy != null ? config.hierarchy : true;            // the influence rung on/off (a clean toggle for tests)
    const inflGain = config.inflGain != null ? config.inflGain : 0.4;                // replicator rate: how fast the salient out-compete the pack for attention (per hour)
    const inflFloor = config.inflFloor != null ? config.inflFloor : 0.05;            // a mind is never fully silenced (keeps the order alive, not locked)

    const cellOf = (v) => clamp(Math.floor((v + 1) / 2 * N), 0, N - 1);
    function blur(F, tmp) {                              // smooth the grid -- an atmosphere is continuous, not per-cell
      for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
        const il = Math.max(0, i - 1), ir = Math.min(N - 1, i + 1), jd = Math.max(0, j - 1), ju = Math.min(N - 1, j + 1);
        tmp[j * N + i] = 0.2 * (F[j * N + i] + F[j * N + il] + F[j * N + ir] + F[jd * N + i] + F[ju * N + i]);
      }
      F.set(tmp);
    }

    return {
      name: config.name || 'society',
      domain: 'society',
      scale: 'collective',
      params: config,

      init() {
        return {
          N, fearField: new Float32Array(N * N), hopeField: new Float32Array(N * N),
          _tmp: new Float32Array(N * N), _wt: new Float32Array(N * N),
          meanFear: 0, meanHope: 0, meanDread: 0, meanGlow: 0, consensus: 0, dominant: 'still', atmosphere: 0,
          scar: 0, legacy: 0, temper: 0, airFear: 0, airHope: 0,
          infl: null, inflMax: 1, inflHerf: 1,
          prevFear: 0, prevHope: 0, evs: []
        };
      },

      step(st, ctx, dt) {
        const md = ctx.fields ? ctx.fields[mindName] : null;
        st.evs.length = 0;
        if (!md || !md.M) return st;
        const M = md.M, ff = st.fearField, hf = st.hopeField, wt = st._wt;
        if (hierarchy && (!st.infl || st.infl.length !== M)) { st.infl = new Float32Array(M); st.infl.fill(1); }   // everyone starts equal
        ff.fill(0); hf.fill(0); wt.fill(0);
        // splat each mind's feeling into the air, weighted by how AWAKE it is (a sleeping mind broadcasts
        // nothing) AND by its INFLUENCE (a heeded mind shapes the mood more than the pack). A small kernel so
        // a feeling carries a little past the body that holds it.
        let mf = 0, mh = 0, md_ = 0, mg = 0, aw = 0;
        for (let k = 0; k < M; k++) {
          const w = md.arousal[k] * (hierarchy && st.infl ? st.infl[k] : 1); aw += w;
          const ci = cellOf(md.x[k]), cj = cellOf(md.y[k]);
          const r = Math.max(0, Math.round(reach));
          for (let dj = -r; dj <= r; dj++) for (let di = -r; di <= r; di++) {
            const i = ci + di, j = cj + dj; if (i < 0 || i >= N || j < 0 || j >= N) continue;
            const kw = w / (1 + di * di + dj * dj), idx = j * N + i;
            ff[idx] += md.fear[k] * kw; hf[idx] += md.hope[k] * kw; wt[idx] += kw;
          }
          mf += md.fear[k]; mh += md.hope[k]; md_ += md.bThreat[k]; mg += md.bWarmth[k];
        }
        for (let n = 0; n < N * N; n++) { const w = wt[n]; if (w > 1e-6) { ff[n] /= w; hf[n] /= w; } }
        for (let b = 0; b < blurIters; b++) { blur(ff, st._tmp); blur(hf, st._tmp); }

        st.meanFear = mf / M; st.meanHope = mh / M; st.meanDread = md_ / M; st.meanGlow = mg / M;

        // --- COLLECTIVE MEMORY -- "the country that remembers itself." A society keeps a slow record of its
        // OWN past feeling: sustained collective FEAR scars the culture, sustained collective HOPE builds a
        // legacy. That memory decays over many days (a culture forgets slowly, so history outlives the event)
        // and, while it lasts, it colours the very air the minds breathe -- a scarred people carry a dread in
        // the air that primes them to fear the next shadow HARDER; a people with a legacy of hope breathe a
        // steadier atmosphere that answers the same shadow with LESS panic. The past is not gone, it sits in
        // the mood, so history shapes the present reaction. Accumulation + events key off the RAW mind feeling
        // (meanFear/meanHope above), never off the memory itself, so the loop runs THROUGH the minds -- laggy,
        // attention-gated, and self-healing as the scar decays -- instead of feeding itself into a runaway.
        let bg = 0;
        if (memory) {
          const dd = clamp((dt || 0) / 86400, 0, 1);                  // step length in sim-days
          const decay = Math.pow(0.5, dd / memHalfDays);              // slow forgetting toward nothing
          st.scar = clamp(st.scar * decay + scarGain * Math.max(0, st.meanFear - memThresh) * dd, 0, 1.5);
          st.legacy = clamp(st.legacy * decay + legacyGain * Math.max(0, st.meanHope - memThresh) * dd, 0, 1.5);
          st.temper = st.scar - st.legacy;                            // net remembered disposition (>0 scarred/jumpy, <0 heartened/steady)
          bg = memBias * st.temper;                                   // the memory's colour laid over the whole air
          for (let n = 0; n < N * N; n++) { ff[n] = clamp(ff[n] + bg, 0, 1); hf[n] = clamp(hf[n] - bg, 0, 1); }
        }
        // --- HIERARCHY / INFLUENCE -- an emergent pecking order. Salience = how AWAKE a mind is times how
        // STRONG and CLEAR its feeling is; the salient are HEARD. It is a replicator dynamic on a fixed budget
        // of collective attention: a mind more salient than the currently-heeded average GROWS its share, the
        // rest shrink, and the total is renormalized so not everyone can lead. So from a flat crowd a few
        // LEADERS crystallize -- and the mood the nation breathes (both the local air, splatted above weighted
        // by influence, AND the global zeitgeist just below) is dominated by THEM, not by the headcount, so a
        // committed, salient minority can carry the whole. Influence decays back toward the pack when
        // conviction fades and never floors to zero, so the order lives and turns rather than locking.
        let zFear = st.meanFear, zHope = st.meanHope;                 // the national zeitgeist -- headcount, unless a hierarchy weights it toward the leaders
        if (hierarchy && st.infl) {
          const infl = st.infl, hh = clamp((dt || 0) / 3600, 0, 6);
          if (hh > 0) {
            let wsum = 0, wsal = 0;
            for (let k = 0; k < M; k++) { const s2 = md.arousal[k] * Math.max(md.fear[k], md.hope[k]); wsum += infl[k]; wsal += infl[k] * s2; }
            const wmean = wsum > 1e-6 ? wsal / wsum : 0;              // the establishment's salience -- you must out-shout the already-heeded average to rise
            let tot = 0;
            for (let k = 0; k < M; k++) { const s2 = md.arousal[k] * Math.max(md.fear[k], md.hope[k]); infl[k] *= Math.exp(inflGain * (s2 - wmean) * hh); if (infl[k] < inflFloor) infl[k] = inflFloor; tot += infl[k]; }
            const norm = tot > 1e-6 ? M / tot : 1;                    // fixed budget: mean influence stays 1
            let mx = 0, herf = 0;
            for (let k = 0; k < M; k++) { infl[k] *= norm; if (infl[k] > mx) mx = infl[k]; herf += infl[k] * infl[k]; }
            st.inflMax = mx;                                          // the top leader, in multiples of the average
            st.inflHerf = herf / M;                                   // concentration: 1 = a flat crowd, higher = a steeper pecking order
          }
          // the national mood is the INFLUENCE-WEIGHTED feeling: whoever holds the attention sets the tone
          let sf = 0, sh = 0, si = 0;
          for (let k = 0; k < M; k++) { sf += infl[k] * md.fear[k]; sh += infl[k] * md.hope[k]; si += infl[k]; }
          if (si > 1e-6) { zFear = sf / si; zHope = sh / si; }
        }
        st.airFear = clamp(zFear + bg, 0, 1);                         // the atmosphere the minds breathe: the (leader-weighted) mood + the memory in the air
        st.airHope = clamp(zHope - bg, 0, 1);
        // CONSENSUS -- how alike the population feels. Low dispersion of each mind's (fear,hope) feeling
        // around the population mean = a society of one mind; high dispersion = a divided one.
        let disp = 0;
        for (let k = 0; k < M; k++) { const df = md.fear[k] - st.meanFear, dh = md.hope[k] - st.meanHope; disp += Math.sqrt(df * df + dh * dh); }
        st.consensus = clamp(1 - (disp / M) / 0.5, 0, 1);
        let amax = 0; for (let n = 0; n < N * N; n++) { const a = Math.max(ff[n], hf[n]); if (a > amax) amax = a; }
        st.atmosphere = amax;
        const d = st.meanHope - st.meanFear;
        st.dominant = Math.abs(d) < 0.08 ? 'divided' : d > 0 ? 'hope' : 'fear';

        // collective EVENTS -- a mood that grips the whole, or a society that splits
        if (st.meanFear > 0.45 && st.meanFear > st.prevFear && st.consensus > 0.5) st.evs.push({ kind: 'panic', mean: st.meanFear, consensus: st.consensus });
        if (st.meanHope > 0.45 && st.meanHope > st.prevHope && st.consensus > 0.5) st.evs.push({ kind: 'rally', mean: st.meanHope, consensus: st.consensus });
        if (st.consensus < 0.35 && st.meanFear > 0.25 && st.meanHope > 0.25) st.evs.push({ kind: 'schism', consensus: st.consensus });
        st.prevFear = st.meanFear; st.prevHope = st.meanHope;
        return st;
      },

      fields(st) {
        return {
          N: st.N, fearField: st.fearField, hopeField: st.hopeField,
          // meanFear/meanHope are the AIR the minds breathe (raw feeling coloured by the memory); the RAW
          // population feeling (what they actually feel, before the memory) is exposed separately.
          meanFear: st.airFear, meanHope: st.airHope,
          meanFearRaw: st.meanFear, meanHopeRaw: st.meanHope,
          meanDread: st.meanDread, meanGlow: st.meanGlow,
          consensus: st.consensus, dominant: st.dominant, atmosphere: st.atmosphere,
          scar: st.scar, legacy: st.legacy, temper: st.temper,
          influence: st.infl, inflMax: st.inflMax, inflHerf: st.inflHerf
        };
      },

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

  return { makeSociety };
});