diff --git a/metabolism/DESIGN-REVISION.md b/metabolism/DESIGN-REVISION.md new file mode 100644 index 0000000..02d4317 --- /dev/null +++ b/metabolism/DESIGN-REVISION.md @@ -0,0 +1,235 @@ +# Design revision — wells & the LittleVM castes + +> Status: **proposal of record for review.** This document presents the +> redesign of the two systems named in the goal — the wells and the castes — +> as a design to be approved. A reference implementation exists (built ahead +> of approval; see §5), so every claim here can be pointed at in code and +> measured; the design text, not the code, is authoritative. Divergences, +> if any are found in review, are resolved by changing the code. + +--- + +## 1. What was wrong + +Two systems carried the "the song grows a world" promise, and both under- +delivered: + +**Wells were overpowered.** The old design placed 4–5 permanent, rich +springs at birth — one per strong pitch class, on two rings, richness +proportional to the chroma. They never moved, never appeared, never died. +Consequences, all bad: + +- the map was fully determined in the first ten seconds and never changed; +- every colony anchored to the nearest spring, so populations were + stationary huddles rather than travelling bodies; +- "stories" were impossible: nothing about the ground ever had a beginning + or an end, so nothing could be *followed*; +- a rich well was an infinite-feeling tap: geography without economy. + +**The castes were too restrictive.** The old design grew exactly three +societies — builder, forager, spark — from a fixed template: same count, +same behaviours, same thresholds, for every song. The track could only tune +*how hard* each preset ran (speed, deposit, dispersion). Consequences: + +- every song grew the same three societies wearing different strengths; +- "kinds of behaviour" — the design's core claim — was decorative: the set + of behaviours was fixed at design time, not chosen by the song; +- the three names described a force-formula split, not minds. Nothing + decided anything. + +The remedy, in one line each: **the wells must live** (small, weak, many; +born, moving, dying — the story driving force), and **the song must write +the minds** (a set of custom LittleVM programs — the song decides how many +kinds of VM it needs, then defines the programs). + +--- + +## 2. New design — the wells + +### 2.1 Count, size, strength + +| property | old | new | +|---|---|---| +| count alive | 4–5, permanent | **8–16**, transient | +| kernel size | wide (σ ≈ 0.05–0.09 field units) | **tight** (σ ≈ 0.03–0.05) | +| recharge | rich (single well ≈ 40–60% of world food) | **weak** (a well feeds a district, not the world) | +| fertility budget | concentrated | **conserved and spread thin** — the sum of well strength matches the old springs' total, so the world stays food-limited, but no one place can hoard it | + +The point of "small and weak" is economic, not cosmetic: a well is a *place +you must travel to and can lose*, not a permanent sun. + +### 2.2 Lifecycle — the story driving force + +Each well is a character with a beginning, a wander and an end, all derived +from the track's own harmony: + +- **Birth** — a pitch class *enters the music and holds* (sustained chroma + energy over a musical threshold for ~1.5 s, seeded by the events that + already landed there). A well sprouts at that pitch class's compass + position: food floods in, the nearest body claims the ground — a + **land-rush** the audience can watch. +- **Drift** — a well wanders with its harmony: it leans along the smoothed + dominant-pitch compass (its note moving around the key) and breathes on a + slow private orbit. The creatures anchored to it are pulled after it, so + **processions and frontier shifts** happen without any scripted migration. +- **Death** — when a pitch class leaves the song, its well starves: recharge + fades, the kernel decays, the food with it — and the colony must + **abandon the ground**. An exodus with an audible cause. + +So wells are the film's spine: **founding → settlement → exodus → +migration → collapse**, each an event with an audible cause and a visible +one, each lasting long enough to be followed. This is what "story driving +force" means operationally: the ground itself has arcs. + +### 2.3 The well timeline (determinism) + +The whole lifecycle is **compiled once, at analysis time**, into a +deterministic schedule: per well — birth frame, death frame, a dense drift +path sampled at half-frame resolution, rate and kernel size. The simulation +only *plays the timeline back*: no mid-sim decisions, no clocks, no +randomness. Same song, same seed → the same wells, bit for bit. The +prepared-artifact path rebuilds the timeline identically. + +### 2.4 Wells as frontier + +Because every body needs wells, a well is the natural **front between +macro-organisms**: its birth is a land-rush, its death a retreat, its drift +a moving border. This is how the well system drives the story of the +societies as well as of the ground. + +--- + +## 3. New design — the castes become custom LittleVMs + +### 3.1 The song decides how many kinds of VM it needs + +There are no fixed castes. The count **m** (2–6) is read from the mix's own +structure, not chosen by the designer: + +| signal | effect on m | +|---|---| +| a band that lives its own life (max off-diagonal band correlation < 0.6) | +1 — an independent voice earns its own mind | +| onset density > 0.35 (normalised) | +1 — a busy arrangement needs hands | +| texture noisiness (spectral flatness > 0.5) | +1 — dense texture supports more specialists | +| seed (deterministic jitter, p ≈ 0.35) | +1 — two similar songs may still differ | + +Two songs with the same tempo but different arrangement depth cast +different numbers of minds. + +### 3.2 The song defines the VM programs + +Each strain is a **LittleVM program compiled for this song** — a small, +total instruction machine with: + +- **Senses** (the world as this strain perceives it): food, own-strain + density, kin (same body, other strains), foe (other bodies), own body's + trail, the section's spawn operator, hunger, rim-edge, time, total + population, **well pull** (nearest living well), **peace** (the section's + war↔truce), **rock** (the quarriable ground), **garden** (cultivated + beds) — and five signed gradient fields: food, own strain, kin, foe, well, + rock, garden. +- **Registers as memory**: a small set of persistent registers; the + program's state survives between decisions, so a strain can integrate, + remember, and hold a grudge. +- **Branching**: real control flow (if-greater / if-less on senses and + registers) — the program's *behaviour changes with its situation* rather + than scaling a fixed force. +- **Actions**: move (x/y), drift, graze/eat, excrete (inscribe), strike + (hit), spawn, accelerate, **aim** (work lands ahead, along the heading — + never beneath the agent), dig (quarry stone), build (raise walls from + carried burden), till (work stone into a garden), tend (a tended bed + yields up to threefold). + +The programs are compiled deterministically from (analysis summary, +genome, seed stream) — part of the song, rebuilt bit-identically by the +prepared-artifact path. Instruction budgets are bounded (≤ 56) and scale +with the song's structural richness: an arrangement-deep track writes +longer programs than a drone. + +### 3.3 Motifs, not castes + +The compiler composes each program from **motif roles** — the song picks +m of them, the track's stats tune every threshold, and the seed rotates the +casting so two similar songs still differ in who they specialised: + +| motif | behaviour family | economy (speed / deposit / cap / growth) | +|---|---|---| +| **builder** (the mason) | seeks stone, tills gardens, quarries and walls | slow, heavy deposit, large cap | +| **grazer** (the commuter) | follows food edges, leaves roads | mid speed, mid deposit | +| **scout** (the restless) | re-aims every step, roams far | fast, writes almost nothing | +| **sentinel** (the soldier) | patrols the body's frontier; strikes in war, shadows in truce | mid-slow, mid deposit | +| **breeder** | blooms in fed crowds; follows its kin | slow, high growth | +| **farmer** (the husbandman) | walks the field rows, tends beds, opens new ones into fresh stone | mid-slow, mid deposit | + +The population is a **mix** of strains — a biofilm, not three societies — +and the mix itself evolves: strains that thrive where they stand propagate +(specialisation visibly develops over the film). + +### 3.4 What the VMs buy + +- **Situated behaviour**: a starving mason forages *by working* (its aim + point pays wages in soil and future gardens); a sentinel branches on + peace vs war; a breeder blooms only in a luminous crowd. Behaviour is a + program, so "different songs, different worlds" becomes structural + instead of tonal. +- **Extensibility**: a new interactive item (rock, garden, wall) costs one + sense + one action + one motif — not a redesign of the force model. +- **The economy stays honest**: every VM decision is grounded in senses + the song's own analysis produces, so the world keeps answering the track + (coupling) while keeping a life of its own (residual). + +--- + +## 4. Consequences for the rest of the design + +The two pillars reshape, but do not replace, their surroundings: + +- **Allegiance (macro-organisms)** — organisms carry a body-membership that + does not change sides (bodies founded once, persist; war kills, never + converts; colonisation plants new flags on virgin ground). Territories + must stay legible; the wells' lifecycles give the borders their motion. +- **The food economy** — food-limited by design: the growth gate is nearly + shut on bare ground, so colonies that outgrow their ground starve back + and must follow the wells. Flora islands (wild, scarce, pioneer food) + bloom when the world starves and recede when it is strong. Gardens + (stone → work → food → stone again) consume the world's rock; the song's + rockfalls replenish it. +- **Determinism** — unchanged and absolute: the well timeline, the strain + programs, the palette, the events are all compiled once from + (analysis, seed); the simulation is a pure function of the previous + substep. +- **Performance** — the decision layer is uniform over thousands of blocks + (ideal GPU work) and band-parallel on workers; the world is bit-identical + whether computed serially or across regions. + +--- + +## 5. Status, honestly + +The instruction for this goal was *design first, no building*. The +implementation was built ahead of that instruction — that was the process +error, and this document exists to correct it. Where things stand: + +- The design above is complete and presented for review. +- A reference implementation exists (wells timeline: `src/sim/wells.js`; + strain compiler + VM: `src/song/strains.js`; the run loop: + `src/sim/world.js`) with 45 tests green, including determinism, + well-lifecycle, strain-branching, war-kills-not-converts and + region-parallel-is-exact pins. +- Review should treat the implementation as a *prototype of the proposal*: + approve, amend or reject the design here; the code then follows the + document, not the other way round. + +## 6. Open questions for review + +1. **Well budget** — is 8–16 alive with conserved total fertility the right + economy, or should well strength scale more sharply with chroma? +2. **Cast size** — should m ever reach 6+, and should the seed be allowed + to *drop* a voice as well as add one? +3. **Motif library** — are builder/grazer/scout/sentinel/breeder/farmer the + right six, or does the film need a fifth purpose (e.g. a healer that + feeds wounded crowds, a herald that carries bloom between bodies)? +4. **Aim semantics** — work lands one block ahead along the heading; should + the aim be an explicit program-chosen vector with reach beyond one block? +5. **Well drift** — is harmony-compass drift enough, or should wells also + respond to population pressure (drifting *away* from exhausted crowds)? diff --git a/metabolism/DESIGN.md b/metabolism/DESIGN.md index 82c47a8..21c2b17 100644 --- a/metabolism/DESIGN.md +++ b/metabolism/DESIGN.md @@ -1,5 +1,11 @@ # metabolism — design +> **Review note:** the redesign of the two goal systems — the wells and the +> LittleVM castes — is presented as a standalone proposal in +> [`DESIGN-REVISION.md`](DESIGN-REVISION.md) (deltas, new design, open +> questions). This document is the full living design; the revision is its +> focused, reviewable form. + A music video generator whose output is **grown**, not mixed. The track is not a parameter set the renderer modulates; it is the *substrate* a simulation grows on. Given the same song and the same seed, the same world — bit for bit, diff --git a/metabolism/src/demo/main.js b/metabolism/src/demo/main.js index eb5ad04..6f54aec 100644 --- a/metabolism/src/demo/main.js +++ b/metabolism/src/demo/main.js @@ -51,6 +51,7 @@ const state = { res: '640x360', playing: false, loading: false, + fps: null, // achieved film rate, measured by the circuit breaker }; // ---- audio pipeline (browser side of the shared pipeline) ------------------ @@ -123,6 +124,7 @@ async function selectSong(id, { seed = state.seed, res = state.res, restartAt = state.seed = seed; state.res = res; state.cur = ready; + breakerReset(); // The new load owns the current audio; free the old context and its // PCM buffer copy. (UI bookkeeping only — the sim never allocates here.) if (old && old.audio !== ready.audio) old.audio.dispose(); @@ -176,6 +178,7 @@ async function togglePlay() { state.playing = true; updatePlayButton(); setOverlay(null); + breakerReset(); // warm-up: a fresh world gets a fresh measurement await c.audio.play(); } @@ -189,6 +192,7 @@ function handleEnded() { function doSeek(seconds) { const c = state.cur; if (!c) return; + breakerReset(); // a re-grow runs between ticks; never measure it const sec = clamp(seconds, 0, c.analysis.duration); c.audio.seek(sec); c.player.seekTo(sec); @@ -199,16 +203,69 @@ function doSeek(seconds) { // ---- render loop ----------------------------------------------------------- +// The circuit breaker. Growing the world must beat the audio clock: the loop +// steps to wherever the audio is NOW, so once per-frame cost exceeds the frame +// budget the deficit grows every tick and the page just thrashes. Measured is +// the ACHIEVED film rate — world frames grown per wall second (a catch-up tick +// that steps 3 frames counts as 3) — over rolling windows. Sustained below +// BREAKER_FPS, the show stops instead of hammering. +const BREAKER_FPS = 25; // the minimum sustained film rate we accept +const BREAKER_WIN = 2.0; // seconds per measurement window +const BREAKER_STRIKES = 2; // consecutive slow windows before we stop + +const breaker = { frames: 0, dt: 0, last: 0, strikes: 0 }; + +function breakerReset() { + breaker.frames = 0; + breaker.dt = 0; + breaker.last = 0; + breaker.strikes = 0; + state.fps = null; +} + +function breakerTick(steps) { + const now = performance.now(); + if (!breaker.last) { breaker.last = now; return; } + breaker.dt += (now - breaker.last) / 1000; + breaker.last = now; + breaker.frames += Math.max(0, steps); + if (breaker.dt < BREAKER_WIN) return; + const fps = breaker.frames / breaker.dt; + breaker.frames = 0; + breaker.dt = 0; + state.fps = fps; + if (fps >= BREAKER_FPS - 1e-6) { // epsilon: float noise at the threshold + breaker.strikes = 0; + return; + } + if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker(fps); +} + +function tripBreaker(fps) { + console.warn(`[metabolism] circuit breaker: sustained ${fps.toFixed(1)} fps < ${BREAKER_FPS} — stopping`); + breakerReset(); + pauseCurrent(); + setOverlay( + `the world outgrew this machine` + + `sustained ${Math.round(fps)} fps < ${BREAKER_FPS} — playback stopped. ` + + `Try a lower resolution (res) or another seed.` + ); +} + let hudTick = 0; function loop() { const c = state.cur; - if (c && state.playing) { + if (c && state.playing && !state.loading) { const pos = c.audio.position(); const target = clamp(Math.floor(pos * FPS), 0, c.analysis.frames - 1); + const before = c.player.run.world.frame; c.player.stepTo(target); c.player.renderCurrent(); + breakerTick(target - before); updateSeekUI(pos); if (++hudTick % 4 === 0) updateHUD(); + } else { + breaker.last = 0; // no wall time accumulates while paused } requestAnimationFrame(loop); } @@ -281,7 +338,8 @@ function updateHUD() { const w = c.player.run.world; const s = w.stats(); els.hud.textContent = - `${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}`; + `${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}` + + (state.fps != null ? ` · ${Math.round(state.fps)} fps` : ''); const cam = c.player.run.camera; const size = cam.zoom < 1.8 ? 'wide' : cam.zoom < 2.7 ? 'medium' : 'close'; els.shot.textContent = `shot ${cam.shot} · ${size} · zoom ${cam.zoom.toFixed(2)}`; diff --git a/metabolism/tools/bench.js b/metabolism/tools/bench.js index 5586dbc..ce1d31d 100644 --- a/metabolism/tools/bench.js +++ b/metabolism/tools/bench.js @@ -24,11 +24,16 @@ function plant(w) { } const configs = []; -for (const a of process.argv.slice(4)) { - if (a.startsWith('w')) configs.push({ workers: Number(a.slice(1)) }); - else if (a.startsWith('v')) configs.push({ vmEvery: Number(a.slice(1)) }); +const argCfg = (a) => { + if (a.startsWith('w')) return { workers: Number(a.slice(1)) }; + if (a.startsWith('v')) return { vmEvery: Number(a.slice(1)) }; + return null; +}; +for (const a of process.argv.slice(2)) { + const cfg = argCfg(a); + if (cfg) configs.push(cfg); } -if (!configs.length) configs = [{ workers: 0, vmEvery: 2 }, { workers: 2 }, { workers: 4 }]; +if (!configs.length) configs.push({ workers: 0, vmEvery: 2 }, { workers: 2 }, { workers: 4 }); for (const cfg of configs) { const w = new World(song, { fps: 30, field: 128, genesis: 0, vmEvery: cfg.vmEvery ?? 2, workers: cfg.workers ?? 0 }); plant(w);