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