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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user