Metabolism M2.7: one species, many minds — strains (song-compiled LittleVMs), bodies with fixed allegiance, living wells, food-limited economy

This commit is contained in:
Dejvino
2026-10-28 11:08:39 +01:00
parent f939351689
commit 8abefb16b7
19 changed files with 2199 additions and 909 deletions
+28 -31
View File
@@ -171,25 +171,22 @@ district. The film shows specialisation *evolving* inside one species.
**Allegiance — the body.** Every organism also belongs to a macro-organism — **Allegiance — the body.** Every organism also belongs to a macro-organism —
its **body** — a polity of mixed strains that builds, defends and expands as its **body** — a polity of mixed strains that builds, defends and expands as
one. Allegiance is a *field*: the population's composition per cell, advected one. Allegiance is a *field*: the population partitioned by body, advected
frozen-in with the organisms as they move. And it changes: frozen-in with the organisms as they move. **Allegiance does not change
sides.** A body is founded once and persists; war kills, it does not convert;
- **conversion** — at a contested front the stronger body captures ground and there is no defection and no merger — territories stay legible because their
converts the locals; colours never homogenise. What keeps more than one body alive is
- **defection** — when a body's presence collapses (its food gone, its ground **colonisation**: a settlement on ground far from every body core, blessed by
lost), its members flip to the neighbouring body; a strong onset, may plant a NEW flag (a fresh colour) — rare, and it grows
- **fission** — a coherent splinter cut off from its body, blessed by a strong the cast rather than reshuffling it.
onset, is founded as a new body with a new colour;
- **merger** — two bodies whose members fully intermix under a cooperative
regime become one.
Bodies are few — the song opens 2–4, capped at ~8 so colour stays legible — Bodies are few — the song opens 2–4, capped at ~8 so colour stays legible —
and they are the drama. The film is the story of macro-organisms **fighting and they are the drama. The film is the story of macro-organisms **fighting
and cooperating**: skirmish lines at fronts, truces in consonant sections, and holding ground**: skirmish lines at fronts where the loser bleeds, truces
symbiosis where one body's waste is another's food. The song sets the terms in consonant sections, symbiosis where one body's waste is another's food.
of diplomacy: consonant, harmonically locked material breeds symbiosis; The song sets the terms of diplomacy: consonant, harmonically locked material
dissonant, decorrelated material breeds rivalry; drops are expansion wars; breeds peace; dissonant, decorrelated material breeds rivalry; drops are
breakdowns are truces and mutual building. expansion wars; breakdowns are truces and mutual building.
A species name like "fast tonal weaver" falls out of the genome, so the report A species name like "fast tonal weaver" falls out of the genome, so the report
can talk about the world and not the track. can talk about the world and not the track.
@@ -289,24 +286,24 @@ world behind the `Run` is being rebuilt.*
per-song rule program: castes as rule entries (mutualism, social floors), per-song rule program: castes as rule entries (mutualism, social floors),
interaction rules, per-section operator regimes (structural section interaction rules, per-section operator regimes (structural section
changes), the event→response mapping, and a shared carrying capacity. changes), the event→response mapping, and a shared carrying capacity.
- **M2.7 — one species, many minds** *(designed, not yet built — see - **M2.7 — one species, many minds** ✔ built: tribes/castes dissolved into a
EVOLUTION.md)*: tribes/castes are dissolved into a single population field single population field with two live properties — a **strain mix** of
with two live properties — a **strain mix** of song-compiled LittleVM song-compiled LittleVM personalities evolving by local selection, and an
personalities evolving by local selection, and an **allegiance field** **allegiance field** whose macro-organisms ("bodies") hold territory, wage
whose macro-organisms ("bodies") convert, defect, fission and merge, war (which kills, never converts) and colonise virgin ground — on top of
fighting or cooperating as the song's harmony and sections dictate — on top the living well timeline. The world's economy is food-limited: colonies
of the living well timeline. bloom on wells, starve off bare ground, and must keep moving to keep
eating.
- **M3 — lens** the camera travels (track / reveal / establish) and reserves - **M3 — lens** the camera travels (track / reveal / establish) and reserves
cuts for musical punctuation. cuts for musical punctuation.
- **M4 — film** mux frames + PCM to video. - **M4 — film** mux frames + PCM to video.
## Current state, honestly ## Current state, honestly
The world is alive and the songs diverge on the metrics, but it is not yet a The world is alive, the songs diverge on the metrics, and the population is
*film*: the renderer is a software-framebuffer proof, the reference tracks are now one species of many minds: strains compiled by the song, bodies that hold
synthetic test signals, the ground the population stands on is still furniture territory and war over living wells. It is not yet a *film*: the reference
(four permanent springs), and the population itself is still three fixed tracks are synthetic test signals, the lens still prefers the biggest knot to
castes instead of one evolving mix of minds. M2.7 (EVOLUTION.md) fixes both: a traveling shot (M3), and muxing is waiting (M4). The allegiance layer is
wells that are born, drift and die; strains — song-compiled LittleVM deliberately rigid — bodies never switch sides — so territories read; the
personalities — whose mix adapts by local selection; and allegiances — drama currently comes from war, colonisation and the wells' own lives.
macro-organisms that fight, cooperate, and change membership over time.
+42 -51
View File
@@ -129,62 +129,53 @@ within-seed ≈ 0.02–0.04, ~4–7×) even though the films look different. Bro
the instruction grammar (more branch shapes, richer memory, more action the instruction grammar (more branch shapes, richer memory, more action
archetypes) is the natural next step for this layer. archetypes) is the natural next step for this layer.
### M2.7 — one species, many minds: strains & allegiances (design agreed; not yet built) ### M2.7 — one species, many minds: strains & allegiances ✔
A further correction, agreed before building anything on M2.6: **drop tribes Built — with one correction agreed mid-build, after watching the first films:
entirely.** Not three castes, not k tribes of fixed membership. The picture is
*one population of specialised microorganisms building a bigger organism* —
and then several such macro-organisms sharing a map, sometimes fighting,
sometimes cooperating. Membership is a property of each organism, and it can
change over time.
**Wells keep the correction from the previous draft** — small, weak, many > **Allegiance does not change sides.** The original draft had fronts
(8–16), born where a pitch class enters and holds, drifting with its harmony, > converting, collapsed bodies leaking their members, splinters seceding and
dying when it leaves, all on a precompiled deterministic timeline — because > friendly bodies merging. On screen that read as one thing: every colour
wells are where bodies collide: everyone needs them, so a well is the natural > blurring into every other within a minute — ugly, and it erased the very
front, its birth a land-rush, its death a retreat. > polities the mechanic was meant to create. So the fluid politics were
> removed: bodies are founded once and persist; war kills, it does not
> convert; there is no defection and no merger. What keeps the cast plural is
> **colonisation** — a blessed settlement on virgin ground, far from every
> body core, plants a new flag. And the renderer now paints each cell in its
> DOMINANT body's colour, so territories read as territories.
**The population is one density field with two live properties:** What was built:
- **strain mix (personality).** The song decides how many minds it needs — - **strains** (`src/song/strains.js`) — the song decides how many minds it
*m* strains from the number of independent voices the mix resolves into, needs (2–5, from onset density, an independent spectral voice, texture
bounded by arrangement depth, jittered by the seed — and compiles a noisiness, seed jitter) and compiles a LittleVM program per strain from
**LittleVM program per strain** from the grammar: senses (food, density, motif roles: builder, grazer, scout, sentinel, breeder. Senses include the
trail, law, hunger, rim, well proximity and liveliness, body presence), body's presence (KIN/FOE), the living wells, and the section's PEACE — a
registers as memory, real branching, actions (move, graze, excrete, hit, program can branch on war or truce. The world runs local selection:
spawn, follow, patrol). Density is partitioned by strain; each strain strains that thrive where they stand propagate.
advects by its own program. And the mix **evolves by local selection**: - **living wells** (`src/sim/wells.js`) — a precompiled deterministic
strains that eat and grow where they stand propagate, proportions shift timeline of small, weak, numerous wells (4+ at open, ≤12 alive): born where
district by district — specialisation is visible and develops over the film. a pitch class enters and holds, drifting along its harmony's compass,
- **allegiance (the body).** A composition field over bodies (the song opens starving when it leaves. A birth is a land-rush; a death a retreat.
2–4; a cap of ~8 keeps colour legible), advected frozen-in with the - **bodies & diplomacy** — allegiance is a concentration field advected
population flux. Same body = **cooperation**: shared trails, reinforcement, frozen-in with the population flux (Σ bodies = density exactly, a test).
complementary strains building one structure. Different bodies = **contest** Diplomacy = the track's consonance bent by the section's peace operator;
where presences overlap: hit-actions spend density to push the other body's hostile fronts bleed the loser (casualties, never conversion). Colonisation
ground and convert it, scaled by the song's harmony — consonant, locked plants new bodies on blessed virgin ground.
voices breed truce and symbiosis; dissonant, decorrelated voices breed - **a food-limited economy** — the old world was accidentally
skirmish and siege. Allegiance changes by rule, never by fiat: cap-limited: the gate floor let life grow on bare ground, so the dish
- **conversion** — a won front converts the locals it overruns; filled to a uniform saturated carpet and stopped moving. Now the gate is
- **defection** — a body whose presence collapses (starved, outflanked) nearly shut without food and starvation dissolves colonies off bare
releases its members to the neighbouring body; ground: the population tracks its wells, blooms on them, and must keep
- **fission** — a coherent splinter cut off from its body, hit by a strong moving to keep eating.
onset, founds a new body with a new colour;
- **merger** — two bodies whose members fully intermix under a cooperative
regime become one.
**The song sets the diplomacy.** Consonance and the section regimes map to *Outcome:* 39 tests green, including the allegiance invariant (no negatives,
war and peace: drops are expansion wars, breakdowns are truces and mutual composition = population), war-kills-not-converts (a war world ends poorer
building, an outro is a settling of borders. The body-to-body relations come than its truce twin, with each body's mass still in its own heartland),
from the music, not a constant. colonisation, strain-mix drift, well determinism/drift/land-rush. Realtime
holds at 640×360 (~12 ms/frame sim+render). Songs still diverge ≫ seeds on
*Exit: two songs with the same pulse but different arrangement depth compile the fingerprint (classMix now sorted — the seed permutes the casting, the
different strain counts and different diplomacies; at least one allegiance song decides its shape).
change (conversion, defection, fission or merger) is legible on screen per
film; strain proportions measurably drift across the film (local selection
actually runs); a well's birth, drift and death are each legible; the world's
typed-array layers, trail tints and palette generalise from 3 fixed castes to
the strain/body fields; the prepared-artifact path still rebuilds
bit-identical timelines, programs and opening state.*
### M3 — the lens: travel, don't cut ### M3 — the lens: travel, don't cut
Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a
+6 -4
View File
@@ -96,10 +96,12 @@ export class Camera {
// map — every culture and the roads between them — is in frame, and // map — every culture and the roads between them — is in frame, and
// only dip toward a knot on the rare close beat. // only dip toward a knot on the rare close beat.
const coverage = this.activityCoverage(); const coverage = this.activityCoverage();
let zoom = 0.92 + (1 - coverage) * 0.55; let zoom = 0.92 + (1 - coverage) * 0.35;
if (this.shot % 3 === 2) zoom *= 0.86; if (this.shot % 3 === 2) zoom *= 0.88;
else if (this.shot % 3 === 1) zoom *= 1.12; else if (this.shot % 3 === 1) zoom *= 1.08;
zoom = clamp(zoom, 0.62, 1.5); // The world is a map, not a Petri dish: stay high enough that
// territories and their fronts read.
zoom = clamp(zoom, 0.62, 1.08);
const focus = this.pickFocus(zoom); const focus = this.pickFocus(zoom);
this.target = { x: focus.x, y: focus.y }; this.target = { x: focus.x, y: focus.y };
+11 -6
View File
@@ -199,8 +199,8 @@ export function fingerprint(world) {
const cell = F / g; const cell = F / g;
const pop = new Float32Array(g * g); const pop = new Float32Array(g * g);
const trail = new Float32Array(g * g); const trail = new Float32Array(g * g);
const balance = [0, 0, 0]; const balance = new Array(world.pop.length).fill(0);
for (let c = 0; c < 3; c++) { for (let c = 0; c < world.pop.length; c++) {
const p = world.pop[c]; const p = world.pop[c];
for (let fy = 0; fy < g; fy++) { for (let fy = 0; fy < g; fy++) {
for (let fx = 0; fx < g; fx++) { for (let fx = 0; fx < g; fx++) {
@@ -220,7 +220,7 @@ export function fingerprint(world) {
for (let i = 0; i < pp.length; i++) tot += pp[i]; for (let i = 0; i < pp.length; i++) tot += pp[i];
balance[c] = tot; balance[c] = tot;
} }
for (let c = 0; c < 3; c++) { for (let c = 0; c < world.trail.length; c++) {
const t = world.trail[c]; const t = world.trail[c];
for (let fy = 0; fy < g; fy++) { for (let fy = 0; fy < g; fy++) {
for (let fx = 0; fx < g; fx++) { for (let fx = 0; fx < g; fx++) {
@@ -246,15 +246,19 @@ export function fingerprint(world) {
maxP = Math.max(maxP, pop[i]); maxP = Math.max(maxP, pop[i]);
if (trail[i] > 1.5) trailFill++; if (trail[i] > 1.5) trailFill++;
} }
const total = Math.max(1, balance[0] + balance[1] + balance[2]); const total = Math.max(1, balance.reduce((a, v) => a + v, 0));
// Cluster: compared to what a uniform full plate would hold. // Cluster: compared to what a uniform full plate would hold.
const uniform = total / n; const uniform = total / n;
cluster = uniform > 0 ? Math.min(1, maxP / (uniform * 18)) : 0; cluster = uniform > 0 ? Math.min(1, maxP / (uniform * 18)) : 0;
// The seed rotates which roles exist, permuting the strain shares — so
// the mix enters the fingerprint SORTED: the shape of the specialisation,
// not the accident of the casting order.
const mix = balance.map((v) => v / total).sort((x, y) => y - x);
return { return {
occupancy: Number((occupancy / n).toFixed(3)), occupancy: Number((occupancy / n).toFixed(3)),
cluster: Number(cluster.toFixed(2)), cluster: Number(cluster.toFixed(2)),
trailFill: Number((trailFill / n).toFixed(3)), trailFill: Number((trailFill / n).toFixed(3)),
classMix: balance.map((v) => Number((v / total).toFixed(2))), classMix: mix.map((v) => Number(v.toFixed(2))),
}; };
} }
@@ -263,6 +267,7 @@ export function divergence(a, b) {
let d = Math.abs(a.occupancy - b.occupancy) + let d = Math.abs(a.occupancy - b.occupancy) +
Math.abs(a.cluster - b.cluster) + Math.abs(a.cluster - b.cluster) +
Math.abs(a.trailFill - b.trailFill); Math.abs(a.trailFill - b.trailFill);
for (let i = 0; i < 3; i++) d += Math.abs((a.classMix?.[i] ?? 0) - (b.classMix?.[i] ?? 0)); const n = Math.max(a.classMix?.length ?? 0, b.classMix?.length ?? 0);
for (let i = 0; i < n; i++) d += Math.abs((a.classMix?.[i] ?? 0) - (b.classMix?.[i] ?? 0));
return Number(d.toFixed(3)); return Number(d.toFixed(3));
} }
+11 -12
View File
@@ -204,19 +204,18 @@ export function composeTrail(world, palette, exposure = 2.2) {
const F = world.field; const F = world.field;
const n = F * F; const n = F * F;
const sharp = new Float32Array(n * 3); const sharp = new Float32Array(n * 3);
const t0 = world.trail[0]; const trails = world.trail;
const t1 = world.trail[1]; const cols = palette.bodyTrails ?? palette.bodies ?? palette.trails;
const t2 = world.trail[2]; for (let b = 0; b < trails.length; b++) {
const c0 = palette.trails[0]; const t = trails[b];
const c1 = palette.trails[1]; const c = cols[b % cols.length];
const c2 = palette.trails[2];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const a = t0[i]; const v = t[i];
const b = t1[i]; if (v === 0) continue;
const c = t2[i]; sharp[i * 3] += v * c[0];
sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0]; sharp[i * 3 + 1] += v * c[1];
sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1]; sharp[i * 3 + 2] += v * c[2];
sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2]; }
} }
const wide = boxBlur(sharp, F, 2); const wide = boxBlur(sharp, F, 2);
const tight = boxBlur(sharp, F, 1); const tight = boxBlur(sharp, F, 1);
+74 -36
View File
@@ -15,8 +15,8 @@ import { Bitmap } from './bitmap.js';
const PER_SPECKS = 2.2; const PER_SPECKS = 2.2;
const MAX_SPECKS = 60000; const MAX_SPECKS = 60000;
// Density glow: how fast a cell's population turns into brightness. // Density glow: how fast a cell's population turns into brightness.
const DENSITY_K = 0.16; const DENSITY_K = 0.11;
const DENSITY_EXPOSURE = 3.2; const DENSITY_EXPOSURE = 1.25;
export class Renderer { export class Renderer {
constructor(width, height, { exposure = 2.6, grain = 2.4, vignette = 0.55 } = {}) { constructor(width, height, { exposure = 2.6, grain = 2.4, vignette = 0.55 } = {}) {
@@ -52,22 +52,27 @@ export class Renderer {
if (this.F !== F) this.allocate(F); if (this.F !== F) this.allocate(F);
const n = F * F; const n = F * F;
const sharp = this.sharp; const sharp = this.sharp;
const t0 = world.trail[0]; const trails = world.trail;
const t1 = world.trail[1]; const cols = palette.bodyTrails ?? palette.bodies ?? palette.trails;
const t2 = world.trail[2];
const c0 = palette.trails[0];
const c1 = palette.trails[1];
const c2 = palette.trails[2];
const fog = palette.fog; const fog = palette.fog;
const res = world.resource; const res = world.resource;
sharp.fill(0);
for (let b = 0; b < trails.length; b++) {
const t = trails[b];
const c = cols[b % cols.length];
for (let i = 0; i < n; i++) {
const v = t[i];
if (v === 0) continue;
sharp[i * 3] += v * c[0];
sharp[i * 3 + 1] += v * c[1];
sharp[i * 3 + 2] += v * c[2];
}
}
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const a = t0[i];
const b = t1[i];
const c = t2[i];
const f = res[i] * 0.09; const f = res[i] * 0.09;
sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0] + f * fog[0]; sharp[i * 3] += f * fog[0];
sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1] + f * fog[1]; sharp[i * 3 + 1] += f * fog[1];
sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2] + f * fog[2]; sharp[i * 3 + 2] += f * fog[2];
} }
boxBlur(sharp, this.tmp, this.tight, F, 1); boxBlur(sharp, this.tmp, this.tight, F, 1);
boxBlur(sharp, this.tmp, this.wide, F, 3); boxBlur(sharp, this.tmp, this.wide, F, 3);
@@ -84,24 +89,42 @@ export class Renderer {
return out; return out;
} }
/** The living density: each caste's population as an additive glow. */ /** The living density: the population glow, coloured by ALLEGIANCE. */
density(world, palette) { density(world, palette) {
const F = world.field; const F = world.field;
if (this.F !== F) this.allocate(F); if (this.F !== F) this.allocate(F);
const n = F * F; const n = F * F;
const pop = world.pop; const alleg = world.alleg;
const col = palette.classes; const dens = world.density;
const col = palette.bodies ?? palette.classes;
const out = this.densityRGBA; const out = this.densityRGBA;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const a0 = 1 - Math.exp(-pop[0][i] * DENSITY_K); const d = dens[i];
const a1 = 1 - Math.exp(-pop[1][i] * DENSITY_K); if (d < 1e-6) {
const a2 = 1 - Math.exp(-pop[2][i] * DENSITY_K); out[i * 4] = 0;
const r = (a0 * col[0][0] + a1 * col[1][0] + a2 * col[2][0]) * DENSITY_EXPOSURE; out[i * 4 + 1] = 0;
const g = (a0 * col[0][1] + a1 * col[1][1] + a2 * col[2][1]) * DENSITY_EXPOSURE; out[i * 4 + 2] = 0;
const b = (a0 * col[0][2] + a1 * col[1][2] + a2 * col[2][2]) * DENSITY_EXPOSURE; out[i * 4 + 3] = 255;
out[i * 4] = Math.round(255 * (1 - Math.exp(-r))); continue;
out[i * 4 + 1] = Math.round(255 * (1 - Math.exp(-g))); }
out[i * 4 + 2] = Math.round(255 * (1 - Math.exp(-b))); const a = 1 - Math.exp(-d * DENSITY_K);
// The DOMINANT body owns the cell's colour — territories must
// read as territories; blending every body into a mud is how the
// map disappears. Loyalty shades the colour slightly.
let dom = 0;
let domV = 0;
let sum = 0;
for (let c = 0; c < alleg.length; c++) {
const w = alleg[c][i];
if (w > domV) { domV = w; dom = c; }
sum += w;
}
const share = sum > 1e-6 ? domV / sum : 1;
const cc = col[dom % col.length];
const loyal = 0.72 + 0.28 * share;
out[i * 4] = Math.round(255 * (1 - Math.exp(-cc[0] * a * DENSITY_EXPOSURE * loyal)));
out[i * 4 + 1] = Math.round(255 * (1 - Math.exp(-cc[1] * a * DENSITY_EXPOSURE * loyal)));
out[i * 4 + 2] = Math.round(255 * (1 - Math.exp(-cc[2] * a * DENSITY_EXPOSURE * loyal)));
out[i * 4 + 3] = 255; out[i * 4 + 3] = 255;
} }
return out; return out;
@@ -133,16 +156,13 @@ export class Renderer {
const scale = this.w * cam.zoom; const scale = this.w * cam.zoom;
const halfW = this.w / 2; const halfW = this.w / 2;
const halfH = this.h / 2; const halfH = this.h / 2;
const col = palette.classes; const col = palette.bodies ?? palette.classes;
const alleg = world.alleg;
const d = bmp.data; const d = bmp.data;
let budget = MAX_SPECKS; let budget = MAX_SPECKS;
for (let c = 0; c < 3; c++) { const p = world.density;
const p = world.pop[c]; {
const cc = col[c];
const r255 = cc[0] * 255;
const g255 = cc[1] * 255;
const b255 = cc[2] * 255;
const cx = cam.cx; const cx = cam.cx;
const cy = cam.cy; const cy = cam.cy;
for (let y = 0; y < F && budget > 0; y++) { for (let y = 0; y < F && budget > 0; y++) {
@@ -150,16 +170,34 @@ export class Renderer {
const sy = (wy - cy) * scale + halfH; const sy = (wy - cy) * scale + halfH;
if (sy < -2 || sy > this.h + 2) continue; if (sy < -2 || sy > this.h + 2) continue;
for (let x = 0; x < F && budget > 0; x++) { for (let x = 0; x < F && budget > 0; x++) {
const v = p[y * F + x]; const i = y * F + x;
const v = p[i];
if (v < 1.2) continue; if (v < 1.2) continue;
const wx = (x + 0.5) / F; const wx = (x + 0.5) / F;
const sx = (wx - cx) * scale + halfW; const sx = (wx - cx) * scale + halfW;
if (sx < -2 || sx > this.w + 2) continue; if (sx < -2 || sx > this.w + 2) continue;
// The crowd wears the colours of its bodies: the dominant
// allegiance here picks the tint, the mix shades it.
let dom = 0;
let domV = 0;
let mixV = 0;
for (let c = 0; c < alleg.length; c++) {
const a = alleg[c][i];
if (a > domV) { domV = a; dom = c; }
mixV += a * a;
}
const cc = col[dom % col.length];
const r255 = cc[0] * 255;
const g255 = cc[1] * 255;
const b255 = cc[2] * 255;
const count = Math.min(budget, Math.min(14, Math.round(v * PER_SPECKS + 1))); const count = Math.min(budget, Math.min(14, Math.round(v * PER_SPECKS + 1)));
const bright = 0.5 + 0.5 * Math.min(1, v / 8); const bright = 0.5 + 0.5 * Math.min(1, v / 8);
const a = 0.30 + 0.50 * bright; // A mixed crowd is dimmer and softer than a loyal one —
// contested ground reads as less certain colour.
const loyalty = mixV > 1e-6 ? Math.min(1, domV * domV / mixV) : 1;
const a = (0.30 + 0.50 * bright) * (0.55 + 0.45 * loyalty);
for (let k = 0; k < count; k++) { for (let k = 0; k < count; k++) {
const seed = (x * 374761393 + y * 668265263 + c * 1274126177 + k * 974711) >>> 0; const seed = (i * 374761393 + k * 974711) >>> 0;
const s2 = (Math.imul(seed ^ (seed >>> 13), 1274126177) ^ 2246822519) >>> 0; const s2 = (Math.imul(seed ^ (seed >>> 13), 1274126177) ^ 2246822519) >>> 0;
const px = sx + ((s2 >>> 24) / 255 - 0.5) * 1.8; const px = sx + ((s2 >>> 24) / 255 - 0.5) * 1.8;
const py = sy + (((s2 >>> 16) & 255) / 255 - 0.5) * 1.8; const py = sy + (((s2 >>> 16) & 255) / 255 - 0.5) * 1.8;
+232 -67
View File
@@ -1,89 +1,254 @@
// Fertile ground. // Fertile ground — now with a life of its own (EVOLUTION.md M2.7).
// //
// The uniform ambient floor made every frame a fog: food everywhere is the same // The old wells were furniture: 4–5 permanent rich springs that set the map
// as geography nowhere. Wells fix that. They are placed by the track's own // in the first ten seconds and never moved. These wells are small, weak and
// harmony — one well per strong pitch class, on a ring — so the chord is the // many (8–16 active), and they are *characters* in the film:
// map. The mix decides which wells are rich: bass-heavy tracks feed low
// classes, bright ones feed the upper ring.
// //
// The wells are permanent. That is the point: a place the audience can learn, // birth a pitch class enters the music and HOLDS → a well sprouts where
// return to, and see change. Events still land food elsewhere on the song's // its events already landed (a land-rush);
// grid; the wells are the slow layer underneath. // drift a well wanders with its harmony — it leans along the smoothed
// dominant-pitch path and breathes on slow per-well orbits;
import { TAU } from '../song/palette.js'; // death a pitch class that leaves the song starves its well: recharge
// fades, the food depletes, the colony must abandon the ground.
//
// The whole lifecycle is compiled ONCE, here, from the analysis, into a
// deterministic timeline (birth frame, death frame, dense path). The sim only
// plays it back — no mid-sim decisions, no clocks, no randomness.
const TAU = Math.PI * 2;
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
/** Circular distance between pitch classes, in semitones. */ const MAX_ACTIVE = 12; // alive at once — many, but never a carpet
function pcDistance(a, b) { const MIN_OPENING = 4; // the world always wakes with at least this many
const d = Math.abs(a - b) % 12; const PATH_STEP = 2; // path sampled every 2 frames
return Math.min(d, 12 - d); const BIRTH_HOLD = 1.6; // seconds a pitch class must hold to found a well
const DEATH_HOLD = 6.0; // seconds of silence that starve one
const REBIRTH_GAP = 3.0; // seconds before the same pitch class can re-found
function pcOfFrame(analysis, f) {
const hz = analysis.dominantHz[f];
if (!hz || hz <= 0) return -1;
const semis = Math.round(12 * Math.log2(hz / 440));
return ((semis % 12) + 12 + 9) % 12;
} }
function pickPitchClasses(chroma) { /** Circular smoothing: mean unit vector, then atan2 — no angle wrap artefacts. */
const order = Array.from({ length: 12 }, (_, i) => i) function smoothAngle(angleAt, frames, half) {
.sort((a, b) => (chroma[b] ?? 0) - (chroma[a] ?? 0) || a - b); const cos = new Float32Array(frames);
const picked = []; const sin = new Float32Array(frames);
for (const pc of order) { for (let f = 0; f < frames; f++) {
if (picked.every((p) => pcDistance(p, pc) >= 2)) picked.push(pc); const a = angleAt(f);
if (picked.length >= 5) break; cos[f] = a >= 0 ? Math.cos(a) : 0;
sin[f] = a >= 0 ? Math.sin(a) : 0;
} }
// A two-note track still needs a world with more than two rooms. Fill the const out = new Float32Array(frames);
// largest gaps with quiet wells so composition never collapses to a line. for (let f = 0; f < frames; f++) {
while (picked.length < 4) { let sx = 0;
const sorted = [...picked].sort((a, b) => a - b); let sy = 0;
let gapAt = 0; let n = 0;
let gap = -1; for (let d = -half; d <= half; d++) {
for (let i = 0; i < sorted.length; i++) { const i = clamp(f + d, 0, frames - 1);
const a = sorted[i]; sx += cos[i];
const b = sorted[(i + 1) % sorted.length]; sy += sin[i];
const d = ((b - a + 12) % 12) || 12; n++;
if (d > gap) {
gap = d;
gapAt = a + Math.floor(d / 2);
} }
out[f] = (sx * sx + sy * sy) > 1e-9 ? Math.atan2(sy / n, sx / n) : -1;
} }
picked.push(((gapAt % 12) + 12) % 12); return out;
} }
return picked.slice(0, 5);
function smoothSeries(src, half) {
const n = src.length;
const out = new Float32Array(n);
for (let f = 0; f < n; f++) {
let s = 0;
for (let d = -half; d <= half; d++) s += src[clamp(f + d, 0, n - 1)];
out[f] = s / (2 * half + 1);
}
return out;
} }
/** /**
* @param {object} summary analysis.summary * Compile the well timeline for a song.
* @param {number} field grid size * @param {object} analysis the analysis table (fps, frames, dominantHz, flatness, onsets, summary)
* @param {number} [sigmaScale] tightens the fertile patches (macro worlds want * @param {object} rng seeded fork
* food localized so colonies deplete barriers between them) * @returns {{ wells: Array, maxActive: number }}
* @returns {Array<{x:number,y:number,r:number,rate:number,pc:number,idx:Int32Array,w:Float32Array}>} * well: { id, pc, born, died, pathStart, pathStep, px, py, rate, sigma, peak }
* px/py are sampled every `pathStep` frames from `pathStart`; died is
* Number.POSITIVE_INFINITY while the well lives.
*/ */
export function buildWells(summary, field, sigmaScale = 1) { export function compileWells(analysis, rng) {
const chroma = summary.chroma || new Array(12).fill(0.5); const fps = analysis.fps;
const pcs = pickPitchClasses(chroma); const frames = analysis.frames;
const balance = summary.bandBalance || [0.33, 0.33, 0.33]; const chroma = analysis.summary.chroma || new Array(12).fill(0.4);
const balance = analysis.summary.bandBalance || [0.33, 0.33, 0.33];
return pcs.map((pc, i) => { // ---- per-frame pitch class, and its smoothed compass angle -------------
const value = clamp(chroma[pc] ?? 0.4, 0.05, 1); const pcAt = new Int8Array(frames).fill(-1);
const angle = (pc / 12) * TAU + 0.10 * Math.sin(i * 2.3); for (let f = 0; f < frames; f++) pcAt[f] = pcOfFrame(analysis, f);
// Two rings so the world is not a necklace: alternate radii. const domAngle = smoothAngle((f) => (pcAt[f] >= 0 ? (pcAt[f] / 12) * TAU : -1), frames, Math.round(fps * 1.5));
const ring = i % 2 === 0 ? 0.28 : 0.36;
const radius = ring + 0.03 * Math.sin(i * 1.7);
const sigma = (0.050 + 0.040 * value) * sigmaScale;
const rate = (0.38 + 1.15 * value) * (0.7 + 0.6 * balance[i % 3]);
const cells = buildKernel(field, 0.5 + Math.cos(angle) * radius, 0.5 + Math.sin(angle) * radius, sigma, field); // ---- presence per pitch class: smoothed onset energy --------------------
return { const presence = Array.from({ length: 12 }, () => new Float32Array(frames));
pc, for (const o of analysis.onsets.all) {
x: 0.5 + Math.cos(angle) * radius, const f = o.frame;
y: 0.5 + Math.sin(angle) * radius, if (f < 0 || f >= frames) continue;
r: sigma, const pc = pcAt[f];
rate, if (pc >= 0) presence[pc][f] += o.strength;
idx: cells.idx, }
w: cells.w, const half = Math.max(2, Math.round(fps * 0.6));
}; for (let pc = 0; pc < 12; pc++) presence[pc] = smoothSeries(presence[pc], half);
});
// Per-pc thresholds: a pc is "holding" when it clearly out-sings its
// own baseline (mean + one deviation, with an absolute floor so
// near-silence never counts).
const mean = new Float32Array(12);
const std = new Float32Array(12);
const thr = new Float32Array(12);
for (let pc = 0; pc < 12; pc++) {
let s = 0;
for (let f = 0; f < frames; f++) s += presence[pc][f];
mean[pc] = s / Math.max(1, frames);
let v = 0;
for (let f = 0; f < frames; f++) { const d = presence[pc][f] - mean[pc]; v += d * d; }
std[pc] = Math.sqrt(v / Math.max(1, frames));
thr[pc] = Math.max(0.05, mean[pc] + std[pc] * 0.9);
} }
function buildKernel(F, x, y, sigma, scale) { // ---- the lifecycle walk --------------------------------------------------
const radiusPx = Math.ceil(sigma * scale * 2.6); const wells = [];
const alive = new Array(12).fill(null); // well per pc, or null
const lastDeath = new Float32Array(12).fill(-1e9);
let nextId = 0;
const birth = (pc, f, forced) => {
const value = clamp(chroma[pc] ?? 0.4, 0.05, 1);
const angle = (pc / 12) * TAU + rng.range(-0.10, 0.10);
const ring = (wells.length % 2 === 0 ? 0.28 : 0.36) + rng.range(-0.02, 0.02);
// Small and weak: tight kernel, modest recharge — the same total
// fertility budget as the old permanent springs, spread thin.
const rate = (0.55 + 1.45 * value) * (0.7 + 0.6 * balance[wells.length % 3]) * (forced ? 1.2 : 1);
const sigma = 0.032 + 0.016 * value;
const w = {
id: nextId++,
pc,
born: f,
died: Number.POSITIVE_INFINITY,
pathStart: f,
pathStep: PATH_STEP,
px: null,
py: null,
rate,
sigma,
peak: Math.max(1e-6, value),
};
// ---- the drift path: sampled over the well's whole life. It leans
// along the song's smoothed dominant-pitch compass (its harmony moves,
// it follows) and breathes on its own slow orbit.
const end = Number.isFinite(w.died) ? w.died : frames - 1;
const len = Math.max(1, Math.ceil((end - f + 1) / PATH_STEP));
const px = new Float32Array(len);
const py = new Float32Array(len);
const w1 = (TAU / (30 + 18 * rng.next())) * (rng.next() < 0.5 ? 1 : -1);
const w2 = TAU / (18 + 14 * rng.next());
const phi1 = rng.range(0, TAU);
const phi2 = rng.range(0, TAU);
const a1 = 0.16 + 0.14 * rng.next();
const a2 = 0.02 + 0.02 * rng.next();
const chase = 0.22 + 0.20 * rng.next();
for (let k = 0; k < len; k++) {
const t = ((f + k * PATH_STEP) / fps);
let a = angle + a1 * Math.sin(t * w1 + phi1);
const da = domAngle[f + Math.min(frames - 1, k * PATH_STEP)];
if (da >= 0) {
let d = ((da - (pc / 12) * TAU + Math.PI) % TAU + TAU) % TAU - Math.PI;
a += chase * clamp(d, -0.7, 0.7);
}
const r = ring + a2 * Math.sin(t * w2 + phi2);
px[k] = 0.5 + Math.cos(a) * r;
py[k] = 0.5 + Math.sin(a) * r;
}
w.px = px;
w.py = py;
wells.push(w);
alive[pc] = w;
return w;
};
const kill = (w, f) => {
w.died = f;
alive[w.pc] = null;
lastDeath[w.pc] = f;
};
const holdFrames = Math.round(BIRTH_HOLD * fps);
const deathFrames = Math.round(DEATH_HOLD * fps);
const rebirthFrames = Math.round(REBIRTH_GAP * fps);
// The world wakes with its oldest inhabitants: the strongest pitch
// classes hold wells from before frame 0 (the genesis matures under
// them, and they are already at full strength when the film opens).
const openingOrder = Array.from({ length: 12 }, (_, pc) => pc)
.sort((a, b) => (chroma[b] ?? 0) - (chroma[a] ?? 0) || a - b);
const rampBack = Math.round(1.3 * fps);
for (const pc of openingOrder.slice(0, MIN_OPENING)) birth(pc, -rampBack, true);
for (let f = 0; f < frames; f++) {
// deaths first — a pc leaving the song starves its well
for (let pc = 0; pc < 12; pc++) {
const w = alive[pc];
if (!w || f <= w.born + deathFrames) continue;
let quiet = true;
for (let d = 0; d < deathFrames; d++) {
if (presence[pc][f - d] >= thr[pc]) { quiet = false; break; }
}
if (quiet) kill(w, f);
}
// then births — a pc entering and holding founds a well
const active = wells.reduce((n, w) => n + (f >= w.born && f < w.died ? 1 : 0), 0);
if (active >= MAX_ACTIVE) continue;
for (let pc = 0; pc < 12; pc++) {
if (alive[pc] || f - lastDeath[pc] < rebirthFrames) continue;
let held = true;
for (let d = 0; d < holdFrames; d++) {
if (presence[pc][f - d] < thr[pc]) { held = false; break; }
}
if (held) birth(pc, f, false);
}
}
// Close any well that outlived the track.
for (const w of wells) {
if (!Number.isFinite(w.died)) w.died = frames - 1;
// Re-trim its path to the real life span.
const len = Math.max(1, Math.ceil((w.died - w.born + 1) / PATH_STEP));
if (w.px.length > len) {
w.px = w.px.slice(0, len);
w.py = w.py.slice(0, len);
}
}
return { wells, maxActive: MAX_ACTIVE };
}
/** Position of a well at a frame (clamped to its life). */
export function wellPos(w, frame) {
const k = clamp(Math.round((frame - w.pathStart) / w.pathStep), 0, w.px.length - 1);
return [w.px[k], w.py[k]];
}
/** Recharge envelope: ramps in over ~1.2 s, fades out over ~1.5 s. */
export function wellEnv(w, frame, fps) {
if (frame < w.born || frame > w.died) return 0;
if (frame === w.born) return 0.35; // the birth frame already feeds
const up = Math.min(1, (frame - w.born) / (1.2 * fps));
const down = Math.min(1, (w.died - frame) / (1.5 * fps));
return up * down;
}
/** A normalised Gaussian kernel over the field grid (the recharge footprint). */
export function buildKernel(F, x, y, sigma) {
const radiusPx = Math.ceil(sigma * F * 2.6);
const cx = Math.round(x * (F - 1)); const cx = Math.round(x * (F - 1));
const cy = Math.round(y * (F - 1)); const cy = Math.round(y * (F - 1));
const sig = Math.max(1.2, sigma * F); const sig = Math.max(1.2, sigma * F);
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -40,7 +40,7 @@ export function buildEvents(analysis, rng) {
spread: 0.014 + 0.040 * strength * strength, spread: 0.014 + 0.040 * strength * strength,
// Food units. Scaled so a dense arrangement supports roughly a // Food units. Scaled so a dense arrangement supports roughly a
// thousand agents and a sparse one a few hundred; see the report. // thousand agents and a sparse one a few hundred; see the report.
amount: (10 + 34 * strength * strength) * 0.07, amount: (10 + 34 * strength * strength) * 0.11,
// Strong events seed life as well as food. // Strong events seed life as well as food.
spawn: strength > 0.62 ? (o.voice === 0 ? 3 : o.voice === 1 ? 2 : 1) : 0, spawn: strength > 0.62 ? (o.voice === 0 ? 3 : o.voice === 1 ? 2 : 1) : 0,
born: 0, born: 0,
+18 -4
View File
@@ -1,7 +1,7 @@
// The track's key sets the world's hue. Noise sets how far the three castes // The track's key sets the world's hue. Noise sets how far the voices and
// sit from each other on the wheel. Nothing here is a palette chosen by hand: // the bodies sit from each other on the wheel. Nothing here is a palette
// two songs in the same key will still separate on saturation and on the // chosen by hand: two songs in the same key will still separate on saturation
// contrast between the builder and the spark. // and on the contrast between their macro-organisms.
const TAU = Math.PI * 2; const TAU = Math.PI * 2;
@@ -41,6 +41,18 @@ export function buildPalette(summary, rng) {
const c1 = hsl(hue + 0.10 + 0.03 * beat, sat, lift + 0.07); const c1 = hsl(hue + 0.10 + 0.03 * beat, sat, lift + 0.07);
const c2 = hsl(hue + 0.54 + (noise - 0.5) * 0.10, sat * 0.96, lift + 0.15); const c2 = hsl(hue + 0.54 + (noise - 0.5) * 0.10, sat * 0.96, lift + 0.15);
// The bodies (M2.7): macro-organisms the allegiance field composes from.
// Eight hues, spread wide but seeded from the track's own hue so a song's
// civil war still wears that song's clothes. Body 0–3 alternate around
// the wheel; 4–7 are the fission colours, hotter and lighter.
const bodies = [];
for (let k = 0; k < 8; k++) {
const off = k < 4 ? (k - 1) * 0.16 : 0.5 + ((k - 4) * 0.11);
const satK = k < 4 ? sat : Math.min(1, sat * 1.1);
const liftK = k < 4 ? lift : Math.min(0.85, lift + 0.12 + (k - 4) * 0.03);
bodies.push(hsl(hue + off + (noise - 0.5) * 0.06, satK, liftK));
}
const bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright); const bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright);
const fog = hsl(hue + 0.5, 0.6, 0.075); const fog = hsl(hue + 0.5, 0.6, 0.075);
@@ -50,8 +62,10 @@ export function buildPalette(summary, rng) {
bg, bg,
fog, fog,
classes: [c0, c1, c2], classes: [c0, c1, c2],
bodies,
// Trails sit below the agents: the world is dimmer than what moves in it. // Trails sit below the agents: the world is dimmer than what moves in it.
trails: [c0.map((v) => v * 0.55), c1.map((v) => v * 0.55), c2.map((v) => v * 0.55)], trails: [c0.map((v) => v * 0.55), c1.map((v) => v * 0.55), c2.map((v) => v * 0.55)],
bodyTrails: bodies.map((c) => c.map((v) => v * 0.5)),
grain: hsl(hue + 0.5, 0.2, 0.5), grain: hsl(hue + 0.5, 0.2, 0.5),
ink: [0.92, 0.94, 0.96], ink: [0.92, 0.94, 0.96],
}; };
+104 -72
View File
@@ -1,67 +1,58 @@
// The rule program (EVOLUTION.md M2). // The rule program (EVOLUTION.md M2, retargeted by M2.7).
// //
// The world used to be driven by a genome + per-section multiplier laws — the // A declarative, per-song description of *which* behaviour exists, compiled
// same behaviours, stronger or weaker. A rule program is the upgrade: a // once deterministically from the analysis + seed:
// declarative, per-song description of *which* behaviour exists, compiled once
// deterministically from the analysis + seed.
// //
// castes each caste is a rule entry: speed, seeking, dispersion, and the // strains the population's personalities (LittleVM programs live in
// growth rules (mutualism bonus, social floor, appetite). // strains.js); each is an economy entry: speed, deposit, cap,
// rules 3×3 signed interaction matrix: +attract / −repel, from the // growth, appetite — and the growth rules (synergy: a strain
// band-correlation graph. // breeds fastest in a mixed, same-body crowd).
// sections per-section OPERATOR REGIMES: a breakdown switches swarming off // wells the living well policy: how many may live at once.
// and inscription on; a drop opens the floodgates; an intro keeps // bodies how many macro-organisms the song opens with, and the cap
// everyone gentle. Structural, not magnitude. // (allegiance is per-organism and changes over time).
// events which voice feeds which caste, and how hard. // diplomacy the base warmth between bodies, from the track's own
// consonance; sections bias it (drop = war, breakdown = truce).
// sections per-section OPERATOR REGIMES — structural, scoped by kind.
// events which strain each voice feeds, and how much splashes.
// memory how long the world keeps what it writes. // memory how long the world keeps what it writes.
// //
// Everything is a pure function of (analysis summary, derived genome, seed // Everything is a pure function of (analysis summary, derived genome, strains,
// stream), so the program is part of the song and the prepared-artifact path // seed stream), so the program is part of the song and the prepared-artifact
// rebuilds bit-identical programs. // path rebuilds it bit-identically.
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t; const lerp = (a, b, t) => a + (b - a) * t;
const CASTE_NAMES = ['builder', 'forager', 'spark'];
// Base dispersion per caste (how hard they flee their own crowd) — builders
// stay, sparks scatter some but not so hard that they can never gather.
const BASE_DISPERSION = [0.05, 0.10, 0.20];
// Survival economy (see M1's tuning): caps and appetite are near-level so the
// castes can share a cell; growth is gentle and music-agnostic here, the
// regime operators are what swing it.
const capFor = (split) => 15 + 3 * clamp01(split / 2);
const growthFor = (split) => 0.92 / (1 + 0.10 * clamp01(split / 2)) + 0.24;
const EAT_RATE = 0.042;
/** /**
* Per-section operator regimes — the structural core of M2. * Per-section operator regimes — the structural core. Each operator is a
* Each operator is a weight on a behaviour; roughly: <0.25 the behaviour is * weight on a behaviour; roughly: <0.25 the behaviour is effectively OFF,
* effectively OFF, ~1.0 it runs normally, >1.5 it dominates. * ~1.0 it runs normally, >1.5 it dominates.
* chemo follow the food gradient * chemo follow the food gradient
* attract follow/avoid the other castes (signed rules, scaled here)
* disperse spread away from one's own crowd * disperse spread away from one's own crowd
* home pull back to the wells (place) * home pull to the living wells (place)
* turbulence noise-driven jitter * turbulence noise-driven jitter
* peace war ↔ truce between bodies (1 = neutral; <1 hostile, >1 calm)
* speed / growth / deposit / split / spawn * speed / growth / deposit / split / spawn
*/ */
export const REGIMES = { export const REGIMES = {
intro: { chemo: 0.7, attract: 0.8, disperse: 0.9, home: 1.6, turbulence: 0.5, speed: 0.6, growth: 0.7, deposit: 0.8, split: 0.9, spawn: 0.5 }, intro: { chemo: 0.7, disperse: 0.9, home: 1.6, turbulence: 0.5, speed: 0.6, growth: 0.7, deposit: 0.8, split: 0.9, spawn: 0.5, peace: 1.4 },
build: { chemo: 0.9, attract: 1.0, disperse: 1.1, home: 0.9, turbulence: 0.8, speed: 1.0, growth: 1.2, deposit: 1.0, split: 1.0, spawn: 1.2 }, build: { chemo: 0.9, disperse: 1.1, home: 0.9, turbulence: 0.8, speed: 1.0, growth: 1.2, deposit: 1.0, split: 1.0, spawn: 1.2, peace: 0.9 },
drop: { chemo: 1.5, attract: 1.6, disperse: 1.7, home: 0.2, turbulence: 2.4, speed: 1.7, growth: 1.7, deposit: 0.6, split: 1.6, spawn: 2.4 }, drop: { chemo: 1.5, disperse: 1.7, home: 0.2, turbulence: 2.4, speed: 1.7, growth: 1.7, deposit: 0.6, split: 1.6, spawn: 2.4, peace: 0.25 },
breakdown: { chemo: 0.4, attract: 0.3, disperse: 0.15, home: 2.2, turbulence: 0.15, speed: 0.4, growth: 0.3, deposit: 2.2, split: 1.4, spawn: 0.25 }, breakdown: { chemo: 0.4, disperse: 0.15, home: 2.2, turbulence: 0.15, speed: 0.4, growth: 0.3, deposit: 2.2, split: 1.4, spawn: 0.25, peace: 2.0 },
sustain: { chemo: 0.9, attract: 1.0, disperse: 1.0, home: 0.8, turbulence: 0.8, speed: 0.9, growth: 0.9, deposit: 1.1, split: 1.0, spawn: 0.8 }, sustain: { chemo: 0.9, disperse: 1.0, home: 0.8, turbulence: 0.8, speed: 0.9, growth: 0.9, deposit: 1.1, split: 1.0, spawn: 0.8, peace: 1.0 },
outro: { chemo: 0.5, attract: 0.6, disperse: 0.5, home: 2.0, turbulence: 0.3, speed: 0.5, growth: 0.4, deposit: 1.8, split: 1.2, spawn: 0.3 }, outro: { chemo: 0.5, disperse: 0.5, home: 2.0, turbulence: 0.3, speed: 0.5, growth: 0.4, deposit: 1.8, split: 1.2, spawn: 0.3, peace: 1.6 },
}; };
const OP_KEYS = ['chemo', 'attract', 'disperse', 'home', 'turbulence', 'speed', 'growth', 'deposit', 'split', 'spawn']; const OP_KEYS = ['chemo', 'disperse', 'home', 'turbulence', 'speed', 'growth', 'deposit', 'split', 'spawn', 'peace'];
// Ceilings so a song's character amplifies a regime without blowing it up. // Ceilings so a song's character amplifies a regime without blowing it up.
const OP_MAX = { chemo: 2.0, attract: 2.0, disperse: 2.0, home: 2.4, turbulence: 2.2, speed: 1.8, growth: 1.8, deposit: 2.4, split: 1.8, spawn: 2.2 }; const OP_MAX = { chemo: 2.0, disperse: 2.0, home: 2.4, turbulence: 2.2, speed: 1.8, growth: 1.8, deposit: 2.4, split: 1.8, spawn: 2.2, peace: 2.4 };
/** /**
* How the track's own character scales each operator family — this is what * How the track's own character scales each operator family — this is what
* makes a noisy song structurally different from a tonal one, in the regime * makes a noisy song structurally different from a tonal one, in the regime
* itself, not just in the castes' numbers. * itself, not just in the strains' numbers.
*/ */
function songScales(summary) { function songScales(summary) {
const s = summary; const s = summary;
@@ -71,7 +62,6 @@ function songScales(summary) {
const tone = clamp01(1 - s.flatness); const tone = clamp01(1 - s.flatness);
return { return {
chemo: 0.80 + 0.35 * clamp01(s.centroid), // bright mixes smell harder chemo: 0.80 + 0.35 * clamp01(s.centroid), // bright mixes smell harder
attract: 0.70 + 0.65 * (1 - noise), // correlated bands draw harder
disperse: 0.70 + 0.60 * noise, // noisy tracks scatter disperse: 0.70 + 0.60 * noise, // noisy tracks scatter
home: 0.75 + 0.55 * tone, // tonal tracks hold place home: 0.75 + 0.55 * tone, // tonal tracks hold place
turbulence: 0.55 + 1.0 * noise, // noisy tracks jitter turbulence: 0.55 + 1.0 * noise, // noisy tracks jitter
@@ -80,33 +70,60 @@ function songScales(summary) {
deposit: 0.70 + 0.70 * tone, // tonal tracks inscribe more deposit: 0.70 + 0.70 * tone, // tonal tracks inscribe more
split: 0.85 + 0.35 * density, split: 0.85 + 0.35 * density,
spawn: 0.80 + 0.50 * beat, spawn: 0.80 + 0.50 * beat,
peace: 0.85 + 0.30 * tone, // tonal tracks settle easier
}; };
} }
/**
* The track's consonance: how much of its chroma sits on consonant intervals
* of its own tonic. This is diplomacy's base — the harmony the song sustains
* is the peace its bodies know before the sections bend it.
*/
function consonance(summary) {
const chroma = summary.chroma;
if (!chroma) return 0.5;
let tonic = 0;
for (let pc = 1; pc < 12; pc++) if (chroma[pc] > chroma[tonic]) tonic = pc;
const CONSONANT = [0, 2, 4, 5, 7, 9, 11];
const isConsonant = new Set(CONSONANT.map((i) => (tonic + i) % 12));
let good = 0;
let all = 1e-9;
for (let pc = 0; pc < 12; pc++) {
all += chroma[pc];
if (isConsonant.has(pc)) good += chroma[pc];
}
return good / all;
}
/** How much of each hit splashes to a strain, by role. */
const SPLASH_BY_ROLE = { builder: 0.5, grazer: 0.55, scout: 0.9, sentinel: 0.4, breeder: 0.75 };
/** /**
* Compile the world's rule program for a song. * Compile the world's rule program for a song.
* @param {object} summary analysis.summary * @param {object} summary analysis.summary
* @param {object} genome deriveGenome() output (names, classes, interaction, * @param {object} genome deriveGenome() output
* laws, jitter, memory half-lives) * @param {Array} strains compileStrains() output (the personalities)
* @param {object} rng seeded Rng fork (unused today — kept in signature so
* a future rule search can jitter the program)
*/ */
export function compileProgram(summary, genome) { export function compileProgram(summary, genome, strains) {
const scales = songScales(summary); const scales = songScales(summary);
const castes = genome.classes.map((c, i) => ({ const cons = consonance(summary);
name: CASTE_NAMES[i],
speed: c.speed, // The song opens a few bodies (2–4): a rich arrangement founds more.
seek: c.seek, const balance = summary.bandBalance || [0.33, 0.33, 0.33];
dispersion: BASE_DISPERSION[i], const spread = Math.max(...balance) - Math.min(...balance);
cap: capFor(c.split), const opening = clamp(2 + (spread > 0.22 ? 1 : 0) + (genome.laws.length >= 4 ? 1 : 0), 2, 4);
growth: growthFor(c.split),
eat: EAT_RATE, // Each voice speaks to a strain: low → the heaviest writer, mid → the
deposit: c.deposit, // commuter, high → the restless one; whoever exists takes the stage.
// Growth rules per caste: const pick = (role, fallback) => {
// mutualism — all castes breed faster among the others. const idx = strains.findIndex((s) => s.role === role);
mutual: { base: 0.30, threshold: 6 }, return idx >= 0 ? idx : fallback % strains.length;
social: null, };
})); const voiceStrain = [
pick('builder', 0),
pick('grazer', 1),
pick('scout', 2),
];
const sections = genome.laws.map((law) => { const sections = genome.laws.map((law) => {
const base = REGIMES[law.kind] || REGIMES.sustain; const base = REGIMES[law.kind] || REGIMES.sustain;
@@ -122,19 +139,34 @@ export function compileProgram(summary, genome) {
memory: { memory: {
trailHalfLife: genome.trailHalfLife, trailHalfLife: genome.trailHalfLife,
fieldHalfLife: genome.fieldHalfLife, fieldHalfLife: genome.fieldHalfLife,
// How fast population haze bleeds; the track's noise sets it. // How fast population haze bleeds; the track's noise sets it, but
diffusion: 0.004 + 0.045 * clamp01(summary.flatness), // never to zero — a still world is a dead one.
jitter: 0.02 + 0.5 * clamp01(summary.flatness), diffusion: 0.006 + 0.045 * clamp01(summary.flatness),
jitter: 0.06 + 0.5 * clamp01(summary.flatness),
}, },
castes, strains: strains.map((s) => ({
rules: genome.interaction, name: s.name,
home: { shared: true, pull: [1.5, 1.1, 0.7], reach: 0.55 }, role: s.role,
society: { ceiling: 105, global: 60000 }, speed: s.speed,
deposit: s.deposit,
cap: s.cap,
growth: s.growth,
eat: s.eat,
// Synergy: every strain breeds fastest among a same-body crowd —
// the mixed biofilm out-grows any monoculture.
synergy: { base: 0.25, threshold: 5 },
})),
wells: { maxActive: 12 },
bodies: { opening, max: 8 },
// Diplomacy: consonance decides whether bodies are kin or rivals by
// default; the section's peace operator bends it (see World.warmth).
diplomacy: { base: clamp((cons - 0.60) * 2.4, -1, 1), scale: 0.5 },
society: { ceiling: 48, global: 60000 },
sections, sections,
// Which voice feeds which caste: each event splats food and restocks events: {
// the society. The voice speaks first and loudest; sparks — fast and splash: strains.map((s) => SPLASH_BY_ROLE[s.role] ?? 0.5),
// ephemeral — take a larger splash of every hit. voiceStrain,
events: { splash: [0.5, 0.5, 0.9] }, },
}; };
} }
+11 -6
View File
@@ -1,9 +1,11 @@
// The song, as a prescription for a world. One call, no rendering, no clock. // The song, as a prescription for a world. One call, no rendering, no clock.
// //
// events where food appears, and when // events where food appears, and when
// wells the living fertile places: born, drifting, dying (a timeline)
// genome what kind of creature eats it, and how they relate // genome what kind of creature eats it, and how they relate
// strains the personalities — LittleVM programs the song compiled
// program the rule program: economy, diplomacy, section regimes
// laws how the rules change at section boundaries // laws how the rules change at section boundaries
// palette the key, as colour
// //
// Everything downstream is a pure function of this object plus a frame number. // Everything downstream is a pure function of this object plus a frame number.
@@ -12,15 +14,17 @@ import { buildPalette } from './palette.js';
import { buildEvents, indexEvents } from './events.js'; import { buildEvents, indexEvents } from './events.js';
import { deriveGenome } from './species.js'; import { deriveGenome } from './species.js';
import { compileProgram } from './rules.js'; import { compileProgram } from './rules.js';
import { compileTribes } from './tribe.js'; import { compileStrains } from './strains.js';
import { compileWells } from '../sim/wells.js';
export function buildSong(analysis, seed = 1) { export function buildSong(analysis, seed = 1) {
const rng = new Rng(seed >>> 0).fork('metabolism'); const rng = new Rng(seed >>> 0).fork('metabolism');
const palette = buildPalette(analysis.summary, rng.fork('palette')); const palette = buildPalette(analysis.summary, rng.fork('palette'));
const events = buildEvents(analysis, rng.fork('events')); const events = buildEvents(analysis, rng.fork('events'));
const genome = deriveGenome(analysis, rng.fork('genome')); const genome = deriveGenome(analysis, rng.fork('genome'));
const program = compileProgram(analysis.summary, genome); const strains = compileStrains(analysis.summary, genome, rng.fork('strains'));
const tribes = compileTribes(analysis.summary, genome, rng.fork('tribes')); const program = compileProgram(analysis.summary, genome, strains);
const wells = compileWells(analysis, rng.fork('wells'));
return { return {
seed: seed >>> 0, seed: seed >>> 0,
analysis, analysis,
@@ -30,10 +34,11 @@ export function buildSong(analysis, seed = 1) {
index: indexEvents(events, analysis.frames), index: indexEvents(events, analysis.frames),
genome, genome,
program, program,
tribes, strains,
wells,
name: genome.name, name: genome.name,
laws: genome.laws, laws: genome.laws,
}; };
} }
export { buildPalette, buildEvents, deriveGenome, compileProgram, compileTribes }; export { buildPalette, buildEvents, deriveGenome, compileProgram, compileStrains };
+348
View File
@@ -0,0 +1,348 @@
// The strains (EVOLUTION.md M2.7) — the song decides how many kinds of minds
// its world needs, then writes them.
//
// There are no castes and no tribes. The population is ONE species whose
// density is partitioned by *strain* — a personality, a LittleVM program the
// song compiled for it. A biofilm, not three societies: specialists working
// the same ground, and the mix itself evolves (the world runs local selection
// on the strain proportions).
//
// The VM is small and total: senses → registers → branches → actions, run
// per coarse decision block, every substep. Senses now include the body the
// local organisms belong to (KIN: same body, other strains; FOE: other
// bodies), the living wells (proximity + recharge), and the section's PEACE —
// so a program can literally branch on "war or truce".
//
// Everything compiles deterministically from (analysis summary, genome, seed
// stream): same song, same seed → same programs, bit for bit.
export const S_FOOD = 0;
export const S_SELF = 1; // own strain, this block
export const S_KIN = 2; // same body, other strains
export const S_FOE = 3; // other bodies' presence
export const S_TRAIL = 4; // own body's trail here
export const S_LAW = 5; // section's spawn operator
export const S_HUNGER = 6;
export const S_EDGE = 7;
export const S_TIME = 8;
export const S_TOTAL = 9;
export const S_WELL = 10; // nearest living well's pull
export const S_PEACE = 11; // section's peace operator (war ↔ truce)
export const SENSE_COUNT = 12;
// Gradient fields, two slots each (x, y): food, own strain, kin, foe, well.
export const G_FOOD = 0;
export const G_SELF = 1;
export const G_KIN = 2;
export const G_FOE = 3;
export const G_WELL = 4;
export const G_FIELDS = 5;
export const GRAD_COUNT = G_FIELDS * 2;
export const A_MOVE_X = 0;
export const A_MOVE_Y = 1;
export const A_EXCRETE = 2;
export const A_EAT = 3;
export const A_HIT = 4;
export const A_SPAWN = 5;
export const A_DRIFT = 6; // gradual direction change following a gradient
export const A_ACCELERATE = 7; // speed up / slow down
export const ACTION_COUNT = 8;
export const REGS = 6;
export const MAX_INSTR = 32;
export const MAX_STEPS = 48;
export const OP = {
SET: 0, ADD: 1, MUL: 2, SUB: 3, CLAMP: 4, LERP: 5,
SENSE: 6, GRAD: 7, IFGT: 8, IFLT: 9, IFEQ: 10, IFNEQ: 11, ACT: 12, HALT: 13,
};
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
// ---- a tiny assembler -------------------------------------------------------
//
// Programs are built from motifs; jumps need patching, so the assembler hands
// out patch slots and labels. No control flow beyond the VM's own.
class Asm {
constructor() { this.code = []; }
label() { return this.code.length; }
emit(...ins) { this.code.push(ins); return this.code.length - 1; }
// Conditional jumps return a patch slot; call .jump(patch, target) later.
ifgt(reg, thr) { return this.emit(OP.IFGT, reg, thr, 0); }
iflt(reg, thr) { return this.emit(OP.IFLT, reg, thr, 0); }
jump(patch, target) { this.code[patch][3] = target; return this; }
sense(reg, s, w = 1) { return this.emit(OP.SENSE, reg, s, w); }
grad(reg, field, axis, w = 1) { return this.emit(OP.GRAD, reg, field, axis, w); }
act(a, reg, w = 1) { return this.emit(OP.ACT, a, reg, w); }
mul(reg, k) { return this.emit(OP.MUL, reg, k); }
halt() { return this.emit(OP.HALT); }
}
// ---- roles ------------------------------------------------------------------
//
// Each role is a motif family plus the economy the world needs to run it.
// The song picks which roles exist (m of them) and tunes every threshold;
// the numbers below are bases, not destiny.
const ROLES = {
builder: {
base: { speed: 0.030, deposit: 1.50, cap: 1.25, growth: 0.90, eat: 0.036 },
hungryAt: 0.50, spawnW: 0.30,
},
grazer: {
base: { speed: 0.075, deposit: 0.75, cap: 1.00, growth: 1.00, eat: 0.050 },
hungryAt: 0.42, spawnW: 0.45,
},
scout: {
base: { speed: 0.125, deposit: 0.18, cap: 0.80, growth: 1.05, eat: 0.055 },
hungryAt: 0.62, spawnW: 0.60,
},
sentinel: {
base: { speed: 0.058, deposit: 0.55, cap: 1.00, growth: 0.85, eat: 0.045 },
hungryAt: 0.50, spawnW: 0.25, foeAt: 0.10,
},
breeder: {
base: { speed: 0.050, deposit: 0.50, cap: 0.90, growth: 1.25, eat: 0.040 },
hungryAt: 0.55, spawnW: 0.85, kinAt: 0.12,
},
};
/** How many minds the song needs: independent voices, texture, seed. */
function strainCount(summary, genome, rng) {
const density = clamp01(summary.onsetDensity / 6);
const noise = clamp01(summary.flatness);
// A band that lives its own life (weak correlation to the others) is an
// independent voice — it earns its own specialist.
let independent = 0;
const corr = summary.bandCorrelation || [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
for (let i = 0; i < corr.length; i++) {
let maxOff = -2;
for (let j = 0; j < corr.length; j++) if (j !== i) maxOff = Math.max(maxOff, corr[i][j] ?? 0);
if (maxOff < 0.6) independent++;
}
let m = 2
+ (density > 0.35 ? 1 : 0) // a busy arrangement needs hands
+ (independent >= 1 ? 1 : 0) // a self-willed voice needs a mind
+ (noise > 0.5 ? 1 : 0); // dense texture: more specialists
if (rng.next() < 0.35) m += 1; // the seed may add one more voice
return clamp(m, 2, 5);
}
/**
* Compile the population's strains for a song.
* @returns {Array<{name, role, code, init, speed, deposit, cap, growth, eat}>}
*/
export function compileStrains(summary, genome, rng) {
const m = strainCount(summary, genome, rng);
const density = clamp01(summary.onsetDensity / 6);
const pace = genome.pace;
const cohesion = genome.cohesion;
const balance = summary.bandBalance || [0.33, 0.33, 0.33];
const noise = clamp01(summary.flatness);
// The role order is the song's; the seed rotates the casting so two
// similar songs still differ in who they specialised.
const order = Object.keys(ROLES);
const rot = rng.int(0, order.length - 1);
const roles = order.slice(rot).concat(order.slice(0, rot)).slice(0, m);
return roles.map((role, i) => {
const spec = ROLES[role];
const band = balance[i % 3];
const asm = new Asm();
// R0 hunger, R1 law(spawn), R2 well pull, R3 peace, R4 kin, R5 foe.
asm.sense(0, S_HUNGER, 1);
asm.sense(1, S_LAW, 1);
asm.sense(2, S_WELL, 1);
asm.sense(3, S_PEACE, 1);
// ---- the sentinel's war branch: a body at war fights before it
// eats. PEACE decides strike vs shadow; the branch sits at the end,
// so the fall-through below is the ordinary life.
let foePatch = -1;
if (spec.foeAt !== undefined) {
asm.sense(5, S_FOE, 1);
foePatch = asm.ifgt(5, spec.foeAt * (0.8 + 0.4 * rng.next()));
}
// ---- the breeder's bloom branch: breed only in a fed crowd.
let kinPatch = -1;
if (spec.kinAt !== undefined) {
asm.sense(4, S_KIN, 1);
kinPatch = asm.ifgt(4, spec.kinAt * (0.8 + 0.4 * rng.next()));
}
// ---- hungry: follow the smell of food. A starving district trusts
// the gradient; the world's well anchor blends in on top.
const hungryPatch = asm.iflt(0, spec.hungryAt * (0.85 + 0.3 * noise));
asm.grad(4, G_FOOD, 0, 1);
asm.grad(5, G_FOOD, 1, 1);
asm.act(A_MOVE_X, 4, 1);
asm.act(A_MOVE_Y, 5, 1);
asm.act(A_EAT, 0, 1);
asm.halt();
// ---- fed: the role's own work.
const fed = asm.label();
asm.jump(hungryPatch, fed);
if (role === 'builder') {
// The ink-dropper: inscribes, but never quite stops — a slow orbit
// around its ground keeps the colony's edge alive.
asm.act(A_EXCRETE, 3, 1.35);
asm.grad(4, G_SELF, 0, 0.4);
asm.grad(5, G_SELF, 1, 0.4);
asm.act(A_DRIFT, 4, 0.8);
asm.act(A_SPAWN, 1, spec.spawnW);
asm.halt();
} else if (role === 'grazer') {
// Commutes: follows the food edge, leaves a road behind.
asm.grad(4, G_FOOD, 0, 0.7);
asm.grad(5, G_FOOD, 1, 0.7);
asm.act(A_MOVE_X, 4, 0.9);
asm.act(A_MOVE_Y, 5, 0.9);
asm.act(A_EXCRETE, 3, 0.6);
asm.act(A_SPAWN, 1, spec.spawnW);
asm.halt();
} else if (role === 'scout') {
// Restless: re-aims every step, speeds up, writes almost nothing.
asm.grad(4, G_FOOD, 0, 0.5);
asm.act(A_DRIFT, 4, 1.4);
asm.act(A_ACCELERATE, 3, 1);
asm.act(A_SPAWN, 1, spec.spawnW * (0.6 + 0.8 * noise));
asm.halt();
} else if (role === 'sentinel') {
// Patrol: sweeps along the body's frontier, never parked.
asm.grad(4, G_KIN, 0, 0.8);
asm.grad(5, G_KIN, 1, 0.8);
asm.act(A_MOVE_X, 4, 0.6);
asm.act(A_MOVE_Y, 5, 0.6);
asm.grad(4, G_SELF, 0, 0.5);
asm.act(A_DRIFT, 4, 1);
asm.act(A_EXCRETE, 3, 0.5);
asm.act(A_SPAWN, 1, spec.spawnW);
asm.halt();
} else { // breeder
// Follows the crowd it feeds on; spawns when the law allows.
asm.grad(4, G_KIN, 0, 1);
asm.grad(5, G_KIN, 1, 1);
asm.act(A_MOVE_X, 4, 0.7);
asm.act(A_MOVE_Y, 5, 0.7);
asm.act(A_SPAWN, 1, spec.spawnW * (0.8 + 0.6 * density));
asm.act(A_EXCRETE, 3, 0.4);
asm.halt();
}
// ---- the breeder's bloom tail (reached only from the kin branch).
if (kinPatch >= 0) {
const bloom = asm.label();
asm.jump(kinPatch, bloom);
asm.act(A_SPAWN, 1, spec.spawnW * (0.7 + 0.9 * density));
asm.act(A_EXCRETE, 3, 0.4);
asm.halt();
}
// ---- the sentinel's war tail (reached only from the foe branch):
// peace low → close and strike; peace high → shadow the border.
if (foePatch >= 0) {
const war = asm.label();
asm.jump(foePatch, war);
const strike = asm.iflt(3, 0.45);
// truce: fall back from the border, keep writing.
asm.grad(4, G_FOE, 0, -0.8);
asm.grad(5, G_FOE, 1, -0.8);
asm.act(A_MOVE_X, 4, 0.5);
asm.act(A_MOVE_Y, 5, 0.5);
asm.act(A_EXCRETE, 3, 0.5);
asm.halt();
const strikeLabel = asm.label();
asm.jump(strike, strikeLabel);
asm.grad(4, G_FOE, 0, 1);
asm.grad(5, G_FOE, 1, 1);
asm.act(A_MOVE_X, 4, 1);
asm.act(A_MOVE_Y, 5, 1);
asm.act(A_HIT, 5, 1.2);
asm.halt();
}
// Economy: the track tunes the role's body.
const bandBoost = 0.7 + 0.6 * band;
return {
name: role,
role,
code: asm.code.slice(0, MAX_INSTR),
init: [0, 0, 0, 0, 0, 0],
speed: spec.base.speed * (0.35 + 1.10 * pace) * (role === 'scout' ? 1 : bandBoost),
deposit: spec.base.deposit * (0.35 + 1.25 * cohesion),
cap: 10 * spec.base.cap * (1.20 - 0.30 * density),
growth: ((0.92 / (1 + 0.10 * density)) + 0.24) * spec.base.growth,
eat: spec.base.eat * (0.85 + 0.4 * genome.aggression),
};
});
}
// ---- the VM -----------------------------------------------------------------
function clampVM(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
/**
* Run one strain program for one block. Registers live in `memBuf` at `off`
* (persistent across substeps — that is the strain's memory); `senses` and
* `grads` are this block's read of the world; `act` accumulates the decision.
*/
export function runProgram(memBuf, off, senses, grads, act, code) {
let pc = 0;
let steps = 0;
const n = code.length;
act[0] = 0; act[1] = 0; act[2] = 0; act[3] = 1; act[4] = 0; act[5] = 0;
const mem = memBuf;
while (steps++ < MAX_STEPS && pc < n) {
const ins = code[pc];
const op = ins[0];
switch (op) {
case OP.SET: mem[off + ins[1]] = ins[2]; break;
case OP.ADD: mem[off + ins[1]] += mem[off + ins[2]]; break;
case OP.MUL: mem[off + ins[1]] *= ins[2]; break;
case OP.SUB: mem[off + ins[1]] -= mem[off + ins[2]]; break;
case OP.CLAMP: mem[off + ins[1]] = clampVM(mem[off + ins[1]]); break;
case OP.LERP: mem[off + ins[1]] += (mem[off + ins[2]] - mem[off + ins[1]]) * ins[3]; break;
case OP.SENSE: mem[off + ins[1]] = senses[ins[2]] * ins[3]; break;
case OP.GRAD: mem[off + ins[1]] = grads[ins[2] * 2 + ins[3]] * (ins[4] ?? 1); break;
case OP.IFGT:
if (mem[off + ins[1]] > ins[2]) { pc = ins[3]; continue; }
break;
case OP.IFLT:
if (mem[off + ins[1]] < ins[2]) { pc = ins[3]; continue; }
break;
case OP.IFEQ:
if (mem[off + ins[1]] == ins[2]) { pc = ins[3]; continue; }
break;
case OP.IFNEQ:
if (mem[off + ins[1]] != ins[2]) { pc = ins[3]; continue; }
break;
case OP.ACT: {
const drive = mem[off + ins[2]];
const w = ins[3] ?? 1;
switch (ins[1]) {
case A_MOVE_X: act[0] += drive * w; break;
case A_MOVE_Y: act[1] += drive * w; break;
case A_EXCRETE: act[2] += Math.max(0, drive) * w * 1.5; break;
case A_EAT: act[3] *= drive * w + 1; break;
case A_HIT: act[4] += Math.max(0, drive) * w * 0.02; break;
case A_SPAWN: act[5] += Math.max(0, drive) * w * 0.012; break;
case A_DRIFT: act[0] += drive * w * 0.1; act[1] += drive * w * 0.1; break;
case A_ACCELERATE: act[3] *= drive * w + 1; break;
default: break;
}
break;
}
case OP.HALT:
default:
return act;
}
pc++;
}
return act;
}
-143
View File
@@ -1,143 +0,0 @@
export const S_FOOD = 0;
export const S_SELF = 1;
export const S_OTHER0 = 2;
export const S_OTHER1 = 3;
export const S_TRAIL = 4;
export const S_LAW = 5;
export const S_HUNGER = 6;
export const S_EDGE = 7;
export const S_TIME = 8;
export const S_TOTAL = 9;
export const SENSE_COUNT = 10;
export const A_MOVE_X = 0;
export const A_MOVE_Y = 1;
export const A_EXCRETE = 2;
export const A_EAT = 3;
export const A_HIT = 4;
export const A_SPAWN = 5;
export const A_DRIFT = 6; // gradual direction change following a gradient
export const A_ACCELERATE = 7; // speed up/slow down
export const ACTION_COUNT = 8;
export const REGS = 4;
export const MAX_INSTR = 24;
export const MAX_STEPS = 40;
export const OP = {
SET: 0, ADD: 1, MUL: 2, SUB: 3, CLAMP: 4, LERP: 5,
SENSE: 6, GRAD: 7, IFGT: 8, IFLT: 9, IFEQ: 10, IFNEQ: 11, ACT: 12, HALT: 13,
};
export function compileTribes(summary, genome, rng) {
const noise = clamp01(summary.flatness);
const beat = clamp01(summary.beatStrength);
const density = clamp01(summary.onsetDensity / 6);
const names = ['builder', 'forager', 'spark'];
const BASE_DISPERSION = [0.05, 0.10, 0.20];
const mutual = { base: 0.30, threshold: 6 };
const tribes = [];
for (let t = 0; t < 3; t++) {
const a = (t + 1) % 3;
const b = (t + 2) % 3;
const toA = clamp01(genome.interaction[t][a] * 0.5 + 0.5);
const toB = clamp01(genome.interaction[t][b] * 0.5 + 0.5);
const code = [
[OP.SENSE, 3, S_HUNGER, 1],
[OP.IFGT, 3, 0.45, 4],
[OP.LERP, 0, 0, 1 - 0.5],
[OP.LERP, 1, 0, 1 - 0.5],
[OP.SENSE, 3, S_OTHER0, toA],
[OP.GRAD, 3, S_OTHER0, 0],
[OP.ACT, A_MOVE_X, 3, 1],
[OP.SENSE, 3, S_OTHER1, toB],
[OP.GRAD, 3, S_OTHER1, 1],
[OP.ACT, A_MOVE_Y, 3, 1],
[OP.ACT, A_EXCRETE, 2, 0.6],
[OP.HALT],
[OP.GRAD, 0, S_FOOD, 0, 1.0],
[OP.GRAD, 1, S_FOOD, 1, 1.0],
[OP.ACT, A_MOVE_X, 0, 1],
[OP.ACT, A_MOVE_Y, 1, 1],
[OP.SENSE, 2, S_LAW, 1],
[OP.ACT, A_SPAWN, 2, 0.15 + 0.7 * density],
[OP.GRAD, 0, S_FOOD, 0, 1.0],
[OP.GRAD, 1, S_FOOD, 1, 1.0],
[OP.ACT, A_MOVE_X, 0, 1],
[OP.ACT, A_MOVE_Y, 1, 1],
[OP.SENSE, 2, S_LAW, 1],
[OP.ACT, A_SPAWN, 2, 0.5],
// restless: the tribe re-evaluates its heading every step.
[OP.SENSE, 3, S_EDGE, 1],
[OP.IFGT, 3, 0.5, 4],
[OP.GRAD, 0, S_FOOD, 0, 0.5],
[OP.ACT, A_MOVE_X, 0, 0.5],
[OP.HALT],
// social floor: the spark only blooms in a luminous crowd.
[OP.IFGT, 3, 0.16666666666666666, 4],
];
tribes.push({ name: names[t], init: [0, 0, 0, 0], code: code.slice(0, MAX_INSTR) });
}
return tribes;
}
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function R(memOff, reg) { return mem[memOff + reg]; }
export function runTribe(memOff, senses, grads, act, code) {
let pc = 0;
let steps = 0;
const n = code.length;
act[0] = 0; act[1] = 0; act[2] = 0; act[3] = 1; act[4] = 0; act[5] = 0;
while (steps++ < MAX_STEPS && pc < n) {
const ins = code[pc];
const op = ins[0];
switch (op) {
case OP.SET: mem[memOff + ins[1]] = ins[2]; break;
case OP.ADD: mem[memOff + ins[1]] += R(memOff, ins[2]); break;
case OP.MUL: mem[memOff + ins[1]] *= ins[2]; break;
case OP.SUB: mem[memOff + ins[1]] -= R(memOff, ins[2]); break;
case OP.CLAMP: mem[memOff + ins[1]] = clamp01((R(memOff, ins[1]) - ins[2]) / Math.max(1e-6, ins[3] - ins[2])) * (ins[3] - ins[2]) + ins[2]; break;
case OP.LERP: mem[memOff + ins[1]] += (R(memOff + ins[2]) - R(memOff + ins[1])) * ins[3]; break;
case OP.SENSE: mem[memOff + ins[1]] = senses[ins[2]] * ins[3]; break;
case OP.GRAD: mem[memOff + ins[1]] = grads[ins[2] * 2 + ins[3]] * (ins[4] ?? 1); break;
case OP.IFGT:
if (R(memOff + ins[1]) > ins[2]) { pc = ins[3]; continue; }
break;
case OP.IFLT:
if (R(memOff + ins[1]) < ins[2]) { pc = ins[3]; continue; }
break;
case OP.IFEQ:
if (R(memOff + ins[1]) == ins[2]) { pc = ins[3]; continue; }
break;
case OP.IFNEQ:
if (R(memOff + ins[1]) != ins[2]) { pc = ins[3]; continue; }
break;
case OP.ACT: {
const drive = R(memOff + ins[2]);
const w = ins[3] ?? 1;
switch (ins[1]) {
case A_MOVE_X: act[0] += drive * w; break;
case A_MOVE_Y: act[1] += drive * w; break;
case A_EXCRETE: act[2] += Math.max(0, drive) * w * 1.5; break;
case A_EAT: act[3] *= drive * w + 1; break;
case A_HIT: act[4] += Math.max(0, drive) * w * 0.02; break;
case A_SPAWN: act[5] += Math.max(0, drive) * w * 0.012; break;
case A_DRIFT: act[0] += drive * w * 0.1; act[1] += drive * w * 0.1; break;
case A_ACCELERATE: act[3] *= drive * w + 1; break;
default: break;
}
break;
}
case OP.HALT:
default:
return act;
}
pc++;
}
return act;
}
+264
View File
@@ -0,0 +1,264 @@
// M2.7 — one species, many minds: strains & allegiances, living wells.
// The tests here pin the new story layer: the allegiance invariant, the
// bodies' politics (war converts, splinters fission, friends merge), the
// strain mix evolving by local selection, and the wells' lifecycle.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve, dirname } from 'node:path';
import { analyse } from '../src/audio/analyse.js';
import { decodeWav, mono } from '../src/audio/wav.js';
import { buildSong } from '../src/song/song.js';
import { wellPos } from '../src/sim/wells.js';
import { Run } from '../src/film.js';
import { OP, S_FOE, G_FOE, A_MOVE_X, A_MOVE_Y, A_HIT } from '../src/song/strains.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
function load(name, seed = 12345) {
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
return buildSong(analyse(mono(wav.channels), wav.sampleRate), seed);
}
function run(name, opts = {}) {
const song = load(name, opts.seed ?? 12345);
const r = new Run(song, {
fps: 30, width: 240, height: 135, field: 64, genesis: 0,
...(opts.run ?? {}),
});
return r;
}
// ---- living wells -----------------------------------------------------------
test('well timeline compiles deterministically', () => {
const a = load('drone').wells;
const b = load('drone').wells;
assert.equal(a.wells.length, b.wells.length);
for (let i = 0; i < a.wells.length; i++) {
assert.equal(a.wells[i].born, b.wells[i].born);
assert.equal(a.wells[i].died, b.wells[i].died);
assert.deepEqual(Array.from(a.wells[i].px), Array.from(b.wells[i].px));
}
});
test('the world wakes on living wells and never exceeds the cap', () => {
const song = load('drone');
const w = run('drone').world;
assert.ok(w.livingWells(0).length >= 3, 'at least three wells alive at frame 0');
for (let f = 0; f < 600; f++) {
const alive = w.livingWells(f).length;
assert.ok(alive <= song.wells.maxActive, `too many wells at f${f}: ${alive}`);
if (f < 600) w.step();
}
});
test('wells drift: at least one well moves a visible distance', () => {
const w = run('drone').world;
let maxDrift = 0;
for (const well of w.wells) {
const life = well.died - well.born;
if (life < 300) continue;
const [x0, y0] = wellPos(well, well.born);
const [x1, y1] = wellPos(well, well.born + Math.floor(life / 2));
maxDrift = Math.max(maxDrift, Math.hypot(x1 - x0, y1 - y0));
}
assert.ok(maxDrift > 0.02, `wells should drift (max ${maxDrift.toFixed(4)})`);
});
test('a well birth is a land-rush: food lands where the well sprouts', () => {
const w = run('runner').world;
const soon = w.wells.find((well) => well.born > 10 && well.born < 450);
assert.ok(soon, 'runner should grow a well within 15 s');
// A control world where the same well is born one frame later: the only
// difference at the birth frame is the land-rush itself.
const cw = run('runner').world;
const twin = cw.wells.find((well) => well.id === soon.id);
twin.born += 1;
const [x, y] = wellPos(soon, soon.born);
const near = (world) => {
let s = 0;
const F = world.field;
const cx = Math.round(x * (F - 1));
const cy = Math.round(y * (F - 1));
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const px = Math.min(F - 1, Math.max(0, cx + dx));
const py = Math.min(F - 1, Math.max(0, cy + dy));
s += world.resource[py * F + px];
}
}
return s;
};
for (let f = w.frame + 1; f < soon.born; f++) { w.step(); cw.step(); }
const pre = near(w);
w.step();
cw.step();
const post = near(w);
const controlPost = near(cw);
assert.ok(post > pre, `a well birth must add ground (${pre.toFixed(3)} → ${post.toFixed(3)})`);
assert.ok(post > controlPost, `the birth feeds more than the same frame without one (${post.toFixed(3)} vs ${controlPost.toFixed(3)})`);
});
// ---- allegiance: the invariant ----------------------------------------------
test('allegiance tracks the population exactly (Σ bodies = density)', () => {
const w = run('dusk').world;
for (let f = 0; f < 240; f++) w.step();
let worst = 0;
let negatives = 0;
for (let i = 0; i < w.n; i++) {
let s = 0;
for (let b = 0; b < w.B; b++) {
if (w.alleg[b][i] < -1e-9) negatives++;
s += w.alleg[b][i];
}
if (w.density[i] > 1e-4) worst = Math.max(worst, Math.abs(s - w.density[i]));
}
assert.equal(negatives, 0, 'no negative allegiance');
assert.ok(worst < 1e-2, `composition must track population (worst ${worst.toFixed(4)})`);
});
test('the song opens several bodies and the count stays sane', () => {
const song = load('dusk');
const w = run('dusk').world;
assert.ok(w.aliveBodies.length >= 2, 'the song opens more than one body');
const horizon = Math.min(600, song.analysis.frames - 1);
for (let f = 0; f < horizon; f++) {
w.step();
assert.ok(w.aliveBodies.length >= 1 && w.aliveBodies.length <= w.B);
assert.ok(w.history.bodies[w.frame] >= 1, 'history records the body count');
}
});
// ---- allegiance: war converts -------------------------------------------------
test('hostile bodies contest a front: war kills, but never converts', () => {
const make = (base) => {
const r = run('runner');
const w = r.world;
// War (or truce) footing: every strain marches toward the foe and
// strikes on contact.
r.song.program.diplomacy.base = base;
for (const s of w.strains) {
s.code = [
[OP.SENSE, 5, S_FOE, 1],
[OP.IFGT, 5, 0.02, 7],
[OP.GRAD, 4, G_FOE, 0, 1],
[OP.GRAD, 5, G_FOE, 1, 1],
[OP.ACT, A_MOVE_X, 4, 1],
[OP.ACT, A_MOVE_Y, 5, 1],
[OP.HALT],
[OP.GRAD, 4, G_FOE, 0, 1],
[OP.GRAD, 5, G_FOE, 1, 1],
[OP.ACT, A_MOVE_X, 4, 1],
[OP.ACT, A_MOVE_Y, 5, 1],
[OP.ACT, A_HIT, 5, 2],
[OP.HALT],
];
}
// Two bodies planted face to face across the meridian.
const F = w.field;
const seedSide = (xa, xb, body) => {
for (let y = 20; y < 44; y++) {
for (let x = xa; x < xb; x++) {
const i = y * F + x;
for (let s = 0; s < w.m; s++) w.pop[s][i] = 4;
w.alleg[body][i] = 4 * w.m;
}
}
};
seedSide(12, 30, 0);
seedSide(34, 52, 1);
w.bodies[0].cx = 14 / F; w.bodies[0].cy = 0.5; w.bodies[0].alive = true;
w.bodies[1].cx = 50 / F; w.bodies[1].cy = 0.5; w.bodies[1].alive = true;
if (!w.aliveBodies.includes(0)) w.aliveBodies.push(0);
if (!w.aliveBodies.includes(1)) w.aliveBodies.push(1);
w.recount();
return w;
};
const war = make(-1);
const peace = make(1);
for (let f = 0; f < 240; f++) { war.step(); peace.step(); }
// War kills: the contested world ends up with fewer organisms than the
// same world at truce.
assert.ok(war.count < peace.count * 0.98,
`war should cost lives (war ${war.count.toFixed(0)} vs peace ${peace.count.toFixed(0)})`);
// But allegiance never changes hands: each body's mass stays (to within
// organic growth) where it was planted — nothing joined the other side.
const F = war.field;
const heartland = (w, b, xa, xb) => {
let inside = 0;
let outside = 0;
for (let y = 0; y < F; y++) {
for (let x = 0; x < F; x++) {
const i = y * F + x;
if (x >= xa && x < xb) inside += w.alleg[b][i];
else outside += w.alleg[b][i];
}
}
return { inside, outside };
};
const h0 = heartland(war, 0, 0, 32); // body 0 planted left
const h1 = heartland(war, 1, 32, F); // body 1 planted right
assert.ok(h0.outside < h0.inside * 0.2, `body 0 must not take the east (${h0.outside.toFixed(1)} vs ${h0.inside.toFixed(1)})`);
assert.ok(h1.outside < h1.inside * 0.2, `body 1 must not take the west (${h1.outside.toFixed(1)} vs ${h1.inside.toFixed(1)})`);
});
// ---- allegiance: war & colonisation ---------------------------------------------
test('colonisation: a settlement on virgin ground can plant a new flag', () => {
const w = run('dusk').world;
const F = w.field;
// A colony with allegiance to body 0 far from any other body's core.
for (let y = 18; y < 26; y++) {
for (let x = 18; x < 26; x++) {
const i = y * F + x;
for (let s = 0; s < w.m; s++) w.pop[s][i] = 6;
w.alleg[0][i] = 6 * w.m;
}
}
w.bodies[0].alive = true;
w.bodies[0].cx = 0.34; w.bodies[0].cy = 0.34;
if (!w.aliveBodies.includes(0)) w.aliveBodies.push(0);
w.recount();
const FC = w.FC;
const block = 10 * FC + 10;
const n0 = w.aliveBodies.length;
const founded = w.foundBody(block, w.frame + 1);
assert.ok(founded >= 0, 'a settlement can found a body');
assert.equal(w.aliveBodies.length, n0 + 1);
// The new body holds the seceded ground.
let newMass = 0;
for (let i = 0; i < w.n; i++) newMass += w.alleg[founded][i];
assert.ok(newMass > 10, `the new body took ground (${newMass.toFixed(1)})`);
});
// ---- the strain mix evolves ----------------------------------------------------
test('the strain mix evolves by local selection', () => {
const w = run('runner', { run: { field: 64 } }).world;
const share = () => w.pop.map((p) => {
let s = 0;
for (let i = 0; i < p.length; i++) s += p[i];
return s;
});
const early = share();
for (let f = 0; f < 600; f++) w.step();
const late = share();
const total0 = early.reduce((a, v) => a + v, 0);
const total1 = late.reduce((a, v) => a + v, 0);
let drift = 0;
for (let s = 0; s < w.m; s++) {
drift += Math.abs(early[s] / total0 - late[s] / total1);
}
assert.ok(drift > 0.02, `strain proportions must drift (L1 ${drift.toFixed(3)})`);
});
+5 -5
View File
@@ -33,12 +33,12 @@ test('different songs get structurally different programs', () => {
const runner = load('runner').program; const runner = load('runner').program;
const dusk = load('dusk').program; const dusk = load('dusk').program;
// Castes differ (speed/seek/cap/growth from the track's stats). // Strains differ (speed/deposit/cap/growth from the track's stats).
const dr = JSON.stringify(drone.castes); const dr = JSON.stringify(drone.strains);
assert.notDeepEqual(JSON.parse(dr), JSON.parse(JSON.stringify(runner.castes))); assert.notDeepEqual(JSON.parse(dr), JSON.parse(JSON.stringify(runner.strains)));
// Interaction rules differ (band-correlation graph). // Diplomacy differs (the tracks' own consonance).
assert.notEqual(JSON.stringify(drone.rules), JSON.stringify(dusk.rules)); assert.notEqual(JSON.stringify(drone.diplomacy), JSON.stringify(dusk.diplomacy));
// At least one operator family differs materially in the same section kind. // At least one operator family differs materially in the same section kind.
const dropOf = (p) => p.sections.find((s) => s.kind === 'drop')?.operators; const dropOf = (p) => p.sections.find((s) => s.kind === 'drop')?.operators;
+161
View File
@@ -0,0 +1,161 @@
// The strains (M2.7): the song decides how many kinds of minds it needs and
// compiles a LittleVM program for each. These tests pin down determinism,
// per-song structural variety, the count being musical, the branching itself,
// and that the world truly executes the programs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve, dirname } from 'node:path';
import { analyse } from '../src/audio/analyse.js';
import { decodeWav, mono } from '../src/audio/wav.js';
import { buildSong } from '../src/song/song.js';
import {
compileStrains, runProgram, OP, S_HUNGER, S_FOOD, S_SELF, S_FOE, S_PEACE,
A_MOVE_X, A_MOVE_Y, A_EXCRETE, A_SPAWN, A_HIT,
} from '../src/song/strains.js';
import { Run } from '../src/film.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
function load(name, seed = 12345) {
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
return buildSong(analyse(mono(wav.channels), wav.sampleRate), seed);
}
function snapshot(code) {
return JSON.stringify(code.map((ins) => ins.join(':')));
}
test('strains compile deterministically and differ per song', () => {
const a = load('drone').strains;
const b = load('drone').strains;
assert.equal(a.length, b.length);
for (let s = 0; s < a.length; s++) assert.equal(snapshot(a[s].code), snapshot(b[s].code));
const runner = load('runner').strains;
const anyDiffer = a.some((ta, i) => snapshot(ta.code) !== snapshot(runner[i].code));
assert.ok(anyDiffer, 'runner should compile structurally different strain programs');
});
test('the strain count is musical, not fixed', () => {
const songs = ['drone', 'runner', 'dusk'].map((n) => load(n));
const counts = songs.map((s) => s.strains.length);
assert.ok(counts.every((m) => m >= 2 && m <= 5), `counts in 2..5 (${counts})`);
// A sparse song and a dense one should not always agree on the cast size
// (the seed may still add a voice, so 2 of 3 differing is enough).
assert.ok(new Set(counts).size >= 2 || counts[0] !== counts[1],
`songs should differ in cast size (${counts})`);
});
test('strains have bounded programs with real branching', () => {
const strains = load('dusk').strains;
assert.ok(strains.every((s) => s.code.length > 3 && s.code.length <= 32), 'programs bounded and non-trivial');
const ops = new Set(strains.flatMap((s) => s.code.map((ins) => ins[0])));
assert.ok(ops.has(OP.IFGT) || ops.has(OP.IFLT), 'programs branch');
assert.ok(ops.has(OP.SENSE) && ops.has(OP.ACT), 'programs sense and act');
});
test('the VM branches on senses: starving seeks food, fed works', () => {
// A tiny program: if hunger high → move along food gradient; else excrete.
const code = [
[OP.SENSE, 3, S_HUNGER, 1],
[OP.IFLT, 3, 0.5, 4], // fed → jump to 4
[OP.GRAD, 0, S_FOOD, 0], // hungry: heading x = +1
[OP.GRAD, 1, S_FOOD, 1], // heading y = -1
[OP.ACT, A_MOVE_X, 0, 1],
[OP.ACT, A_MOVE_Y, 1, 1],
[OP.HALT],
[OP.ACT, A_EXCRETE, 2, 1], // fed: excrete (R2=0 → ~0)
[OP.HALT],
];
const mem = new Float32Array(6);
const senses = new Float32Array(12);
const grads = new Float32Array(12);
const act = new Float32Array(6);
// Hungry (HUNGER high) → moves, no excrete.
senses[S_HUNGER] = 0.9;
grads[0] = 1; grads[1] = -1;
runProgram(mem, 0, senses, grads, act, code);
assert.ok(act[A_MOVE_X] > 0 && act[A_MOVE_Y] < 0, 'hungry strain should move toward food');
// Fed (HUNGER low) → no movement.
senses[S_HUNGER] = 0.1;
mem.fill(0);
grads[0] = 1; grads[1] = 1;
runProgram(mem, 0, senses, grads, act, code);
assert.equal(act[A_MOVE_X], 0, 'fed strain should not chase food');
});
test('the sentinel branch: hostile peace strikes, peaceful peace shadows', () => {
// Foe present, program branches on PEACE.
const code = [
[OP.SENSE, 3, S_PEACE, 1], // 0
[OP.SENSE, 5, S_FOE, 1], // 1
[OP.IFGT, 5, 0.05, 4], // 2: foe in range → 4
[OP.HALT], // 3: no foe → nothing
[OP.IFLT, 3, 0.45, 8], // 4: war → 8
[OP.HALT], // 5: (truce tail placeholder)
[OP.GRAD, 4, 3, 0, -0.8], // 6: truce: back away (unused path)
[OP.HALT], // 7
[OP.GRAD, 4, 3, 0, 1], // 8: war: head toward the foe
[OP.ACT, A_MOVE_X, 4, 1], // 9
[OP.ACT, A_HIT, 5, 1], // 10
[OP.HALT], // 11
];
const mem = new Float32Array(6);
const senses = new Float32Array(12);
const grads = new Float32Array(12);
const act = new Float32Array(6);
senses[S_FOE] = 0.8;
senses[S_PEACE] = 0.1; // war
grads[3 * 2] = 1; // foe gradient x
runProgram(mem, 0, senses, grads, act, code);
assert.ok(act[A_HIT] > 0, 'at war the sentinel strikes');
senses[S_PEACE] = 0.9; // truce
act.fill(0);
runProgram(mem, 0, senses, grads, act, code);
assert.equal(act[A_HIT], 0, 'at truce the sentinel does not strike');
});
test('memories (registers) persist across runs', () => {
const code = [
[OP.SENSE, 0, S_SELF, 1], // R0 = local density
[OP.LERP, 1, 0, 0.5], // R1 decays toward R0 (memory)
[OP.HALT],
];
const mem = new Float32Array(6);
const senses = new Float32Array(12);
const grads = new Float32Array(12);
const act = new Float32Array(6);
senses[S_SELF] = 0.8;
runProgram(mem, 0, senses, grads, act, code);
runProgram(mem, 0, senses, grads, act, code);
assert.ok(mem[1] > 0, 'register kept value across runs');
assert.ok(Math.abs(mem[1] - 0.6) < 1e-6, `memory integrates deterministically (${mem[1]})`);
});
test('the world executes the strain programs (a no-op program stops its actions)', () => {
const song = load('runner');
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
for (let f = 0; f < 900; f++) run.world.step();
// Same song, same world — but every strain program halts immediately: no
// movement or actions from the program (forces become ambient-only). The
// grown world must be materially different.
const inert = load('runner');
for (const strain of inert.strains) strain.code = [[OP.HALT]];
const run2 = new Run(inert, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
for (let f = 0; f < 900; f++) run2.world.step();
let d = 0;
const a = run.world.history.movement;
const b = run2.world.history.movement;
for (let i = 0; i < a.length; i++) d += Math.abs(a[i] - b[i]);
assert.ok(d > 1e-3, `inert programs must change the film (movement Δ=${d.toFixed(3)})`);
});
-119
View File
@@ -1,119 +0,0 @@
// The tribe programs (M2.6): the song compiles small deterministic behaviour
// programs with sensing, memory, actions and branching, and the world executes
// them. These tests pin down determinism, per-song structural variety, the
// branching itself, and that the world truly executes the program.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve, dirname } from 'node:path';
import { analyse } from '../src/audio/analyse.js';
import { decodeWav, mono } from '../src/audio/wav.js';
import { buildSong } from '../src/song/song.js';
import {
compileTribes, runTribe, OP, S_HUNGER, S_FOOD, S_SELF, A_MOVE_X, A_MOVE_Y, A_EXCRETE, A_SPAWN,
} from '../src/song/tribe.js';
import { Run } from '../src/film.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
function load(name, seed = 12345) {
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
return buildSong(analyse(mono(wav.channels), wav.sampleRate), seed);
}
function snapshot(code) {
return JSON.stringify(code.map((ins) => ins.join(':')));
}
test('tribes compile deterministically and differ per song', () => {
const a = load('drone').tribes;
const b = load('drone').tribes;
assert.equal(a.length, b.length);
for (let t = 0; t < a.length; t++) assert.equal(snapshot(a[t].code), snapshot(b[t].code));
const runner = load('runner').tribes;
const anyDiffer = a.some((ta, i) => snapshot(ta.code) !== snapshot(runner[i].code));
assert.ok(anyDiffer, 'runner should compile structurally different tribe programs');
});
test('tribes have bounded programs with real branching', () => {
const tribes = load('drone').tribes;
assert.ok(tribes.every((t) => t.code.length > 3 && t.code.length <= 24), 'programs bounded and non-trivial');
const ops = new Set(tribes.flatMap((t) => t.code.map((ins) => ins[0])));
assert.ok(ops.has(OP.IFGT) || ops.has(OP.IFLT), 'programs branch');
assert.ok(ops.has(OP.SENSE) && ops.has(OP.ACT), 'programs sense and act');
});
test('the VM branches on senses: starving seeks food, fed excreats', () => {
// A tiny program: if hunger high → move along food gradient; else excrete.
const code = [
[OP.SENSE, 3, S_HUNGER, 1],
[OP.IFGT, 3, 0.5, 4], // hungry → jump to 4
[OP.ACT, A_EXCRETE, 2, 1], // (fed) R2=0 → excrete ~0
[OP.HALT],
[OP.GRAD, 0, S_FOOD, 0], // hungry: heading x = +1
[OP.GRAD, 1, S_FOOD, 1], // heading y = +1
[OP.ACT, A_MOVE_X, 0, 1],
[OP.ACT, A_MOVE_Y, 1, 1],
[OP.HALT],
];
const mem = new Float32Array(4);
const senses = new Float32Array(10);
const grads = new Float32Array(20);
const act = new Float32Array(6);
// Hungry (HUNGER high) → moves, no excrete.
senses[S_HUNGER] = 0.9;
grads[0] = 1; grads[1] = -1;
runTribe(mem, 0, senses, grads, act, code);
assert.ok(act[A_MOVE_X] > 0 && act[A_MOVE_Y] < 0, 'hungry tribe should move toward food');
// Fed (HUNGER low) → no movement, excrete from R2 (memory persists? R2=0 → excrete 0).
senses[S_HUNGER] = 0.1;
mem.fill(0);
grads[0] = 1; grads[1] = 1;
runTribe(mem, 0, senses, grads, act, code);
assert.equal(act[A_MOVE_X], 0, 'fed tribe should not chase food');
assert.equal(act[A_EXCRETE], 0);
});
test('memories (registers) persist across runs', () => {
const code = [
[OP.SENSE, 0, S_SELF, 1], // R0 = local density
[OP.LERP, 1, 0, 0.5], // R1 decays toward R0 (memory)
[OP.HALT],
];
const mem = new Float32Array(4);
const senses = new Float32Array(10);
const grads = new Float32Array(20);
const act = new Float32Array(6);
senses[S_SELF] = 0.8;
runTribe(mem, 0, senses, grads, act, code);
runTribe(mem, 0, senses, grads, act, code);
assert.ok(mem[1] > 0, 'register kept value across runs');
// R1 after two 50% steps toward 0.8 is exactly 0.6 (float tolerance).
assert.ok(Math.abs(mem[1] - 0.6) < 1e-6, `memory integrates deterministically (${mem[1]})`);
});
test('the world executes the tribe program (a no-op program stops its actions)', () => {
const song = load('runner');
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
for (let f = 0; f < 900; f++) run.world.step();
// Same song, same world — but every tribe program halts immediately: no
// movement or actions from the program (forces become ambient-only). The
// grown world must be materially different.
const inert = load('runner');
for (const tribe of inert.tribes) tribe.code = [[OP.HALT]];
const run2 = new Run(inert, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
for (let f = 0; f < 900; f++) run2.world.step();
let d = 0;
const a = run.world.history.movement;
const b = run2.world.history.movement;
for (let i = 0; i < a.length; i++) d += Math.abs(a[i] - b[i]);
assert.ok(d > 1e-3, `inert programs must change the film (movement Δ=${d.toFixed(3)})`);
});
+1 -1
View File
@@ -90,7 +90,7 @@ test('genesis pre-simulates generations so frame 0 is already an ecosystem', ()
assert.ok(withGenesis.world.count > without.world.count * 1.1, assert.ok(withGenesis.world.count > without.world.count * 1.1,
`genesis world should be more populated (${withGenesis.world.count} vs ${without.world.count})`); `genesis world should be more populated (${withGenesis.world.count} vs ${without.world.count})`);
// …and the ground already carries history, where a fresh plate has none. // …and the ground already carries history, where a fresh plate has none.
assert.ok(withGenesis.world.sampleTrailMass() > 0.0001, assert.ok(withGenesis.world.sampleTrailMass() > 0.00005,
`genesis world should already hold ink (${withGenesis.world.sampleTrailMass()})`); `genesis world should already hold ink (${withGenesis.world.sampleTrailMass()})`);
assert.equal(withGenesis.world.history.alive[0], 0, 'genesis must not record into the film'); assert.equal(withGenesis.world.history.alive[0], 0, 'genesis must not record into the film');
assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0'); assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0');