diff --git a/docs/evolution_model.md b/docs/evolution_model.md new file mode 100644 index 0000000..181a837 --- /dev/null +++ b/docs/evolution_model.md @@ -0,0 +1,121 @@ +# Evolution model + +How a Fluodditylike is encoded, varied, and selected. Companion to +`cppn_picbreeder_design.md` (the indirect-encoding rationale) and `v0_normalization.md` +(the substrate). This document is the **canonical reference for the genotype→phenotype map +and the breeding loop** — read it before adding any new source of variation. + +## Genotype → phenotype pipeline + +``` +CPPN genome ──[compile: random-feature least-squares refit]──► 244-float rule ──[engine]──► dynamic phenotype + (graph) (lossy, deterministic per genome) (plane waves) (stochastic sim) + + +morphology vector (10 scalars) ─────────────────────────────────────────────────────────┘ +``` + +- **CPPN** (`genome/cppn.ts`) — a small DAG of typed-activation nodes (identity/sin/gauss/abs/ + tanh) mapping the 4D sensor reading `(L,R) → (force, strafe)`. This is locus A, the genome. +- **Compile** (`genome/compile.ts`) — samples the CPPN over the 4D input domain, picks random + cosine features, and least-squares fits the 4 outputs to the 244-float plane-wave rule. It is + **lossy** (`fitError`) and **deterministic per genome** (feature RNG seeded by `COMPILE_OPTS.seed`, + default 7). A child's rule differs *only because its genome differs*. +- **Morphology** (the 10 v0 dynamics scalars, split organism/environment per + `v0_normalization.md`) — see "Two loci" below. +- **Phenotype** — a *dynamic, stochastic* field. Unlike Picbreeder's static images, judging a + candidate means watching it evolve, and same-genome/different-seed varies. This raises the + cost and noise of selection (see "Why pure selection strains here"). + +## The single variation operator + +There is exactly one: **`CPPN.mutate()`** (`cppn.ts`). Everything novel routes through it — +the Mutate button, breeding (`spawnChildren`), and fresh lineages (`freshGenome` = +`minimal()` + 20× `mutate`). Default behaviour per call: + +| op | rate | effect | +|----|------|--------| +| perturb weight | p=0.8 / enabled conn | `weight += N(0, 0.5)` (dominant effect) | +| perturb bias | p=0.2 / non-input node | `bias += N(0, 0.3)` | +| mutate activation | p=0.1 / non-input node | replace with a random activation (the *CPPN* part) | +| add connection | p=0.15 | random valid acyclic `src→dst`, weight `N(0,1)` | +| add node | p=0.08 | split an enabled conn: disable it, insert hidden node, `src→new=1.0`, `new→dst=original` (function-preserving — the NEAT trick) | + +## Breeding = interactive evolutionary computation (IEC) + +Selection is the **human**, not a fitness function. The loop (`state/generation.svelte.ts`, +`evolution/spawn.ts`, `ui/BreederGrid.svelte`): + +1. **First grid:** `randomGenomes(9)` — nine independent `freshGenome` lineages (diverse spread). +2. **Inspect:** hover a tile to animate it; click to view it full-screen. +3. **Select & breed:** "Breed from this" makes the focused organism the parent; + `spawnChildren([parent], 9)` calls `parent.mutate()` nine times → nine single-step variants. + Two selected parents → `a.crossover(b).mutate()`. +4. Repeat. The DAG of saved selections is the lineage (`parents[]`, never rewritten). + +Note the application topology: the **Mutate button walks a chain** (`current = current.mutate()`); +**breeding makes a star** (nine children off the *unchanged* parent). Same operator, both ways. + +## Two loci: rule (CPPN) vs morphology (direct) — and why + +The rule is indirectly encoded (CPPN). The **morphology vector is directly encoded**: it lives +as plain scalars in the record (`Morphology`) and in the live `organism` store, and is varied by +a small Gaussian operator (`evolution/morph.ts`, `mutateMorph`) with **per-parameter sigmas that +are operator config, not record fields** (sigma 0 = a fixed gene). Sensor angle/distance carry the +largest sigmas — they're the sensitive shape controls. + +### Why direct (D) and not CPPN-encoded (A) — for now + +We considered making morphology extra CPPN output heads read at a canonical query point +("Option A"), which keeps everything under the *one* mutate operator and gives **pleiotropy** +(rule and body co-vary coherently — very Picbreeder). We chose **direct embedding ("Option D")** +because of a mechanical fact: + +> **Manual control ⟺ direct embedding.** You cannot hand-tune a parameter that is a CPPN output +> without storing an override — and an override *is* a direct gene. So keeping the morphology +> sliders live (the "tuning shapes the patterns" workflow) already commits us to a direct +> representation; adding a mutation sigma on top is incremental, not a second paradigm. + +Trade-off accepted: D gives **evolvability + manual control** but loses A's coherent rule↔body +coupling. A is more faithful to Picbreeder's *non-objective* philosophy (you discover, you don't +fiddle toward a target). + +### Why pure selection strains here (and the real blocker) + +Pure CPPN + pure selection is most faithful, but Picbreeder relied on a phenotype that is static, +deterministic, and readable at a glance. Fluoddity's is dynamic, stochastic, and **large/coupled**: +morphology and rule interact, so much of the random space is *dead* (collapsed/saturated). The +blocker for pure selection is therefore **viability**, not encoding — a grid of corpses is a bad +chooser. The clean, Picbreeder-compatible fix is the **viability filter** (M5: reject/resample +dead candidates), *not* manual control. Manual control is a heavier crutch that also doubles as a +creative instrument. + +### Path (this is an experiment, not a verdict) + +The record stores the *resolved* morphology vector regardless of source, so A and D can coexist +as a selectable **morphology-source mode**. Plan: + +1. **Now: ship D** — per-candidate morphology, jittered on spawn; sliders stay live overrides; + focusing a candidate loads its evolved morphology. +2. **Later: add A + the viability filter together**, then run the empirical comparison — which + mode surfaces more interesting structure on *this* substrate? +3. *(Fancy, post-MVP):* re-fit a CPPN to hand-authored morphology values (inverse of compile) so + manually-tuned organisms become breedable CPPN genomes. Noted, not planned. + +## Relationship to NEAT / HyperNEAT + +- **vs NEAT:** we have CPPN-NEAT's *encoding and operators* (graph genome, add-node/add-conn, + function-preserving insertion, weight/bias/**activation** mutation) — minus **innovation + numbers** (crossover matches genes by `(src,dst)` endpoints, not historical markings → some + competing-conventions risk) and minus **speciation + a fitness loop**. The latter is *omitted + by design*: IEC replaces fitness with the human. Distance to "full NEAT genome": just innovation + numbers (small, worthwhile only if crossover starts mattering). Distance to "full NEAT + algorithm": N/A — its selection machinery is what IEC discards. +- **vs HyperNEAT:** HyperNEAT queries a CPPN over a *geometric substrate* to generate a large + weight matrix, exploiting spatial regularity. We use the CPPN as a **direct function** + (Picbreeder-style), not a substrate generator. Not useful for the ~10 morphology scalars. The + one place it could fit later: **cohorts** (Starfish's 64) — a CPPN mapping *cohort coordinate → + per-cohort rule perturbation* would give coherent spatial variety. Revisit with the v1 cohort + feature. +- **Bracketed (future, automated selection):** quality-diversity / MAP-Elites / minimal-criterion + coevolution. These reintroduce a fitness-or-novelty signal + (often) speciation. Out of scope + while selection is purely interactive. diff --git a/src/App.svelte b/src/App.svelte index f03f3fa..f7a46a4 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -21,6 +21,7 @@ newGeneration, breedFromActive, memberCppn, + memberMorph, markBuilt, } from "./state/generation.svelte"; import Controls from "./ui/Controls.svelte"; @@ -118,6 +119,7 @@ function onSelect(i: number) { if (!engine) return; view.focusIndex = i; + Object.assign(organism, memberMorph(i)); // adopt the candidate's evolved morphology (sliders show it, stay editable) engine.setMode("single"); engine.setParams(substrateParams()); engine.setDepositGain(organism.depositGain); diff --git a/src/evolution/morph.ts b/src/evolution/morph.ts new file mode 100644 index 0000000..3dd9c9e --- /dev/null +++ b/src/evolution/morph.ts @@ -0,0 +1,50 @@ +// Morphology genes — the directly-encoded half of the genotype (docs/evolution_model.md, "Two +// loci"). The rule is indirectly encoded (CPPN); these ~8 organism scalars are plain numbers that +// evolve by small Gaussian jitter and stay hand-tunable in the editor. +// +// Pure TS (no Svelte, no WebGL): this is the single source of truth for the organism morphology +// parameters — their slider ranges (UI) AND their mutation strengths (operator config). Per the +// data-model discipline, the sigmas live HERE, not in the saved record: sigma is how hard the +// breeder pushes a gene, not a property of the organism. sigma = 0 freezes a gene. + +import type { Rng } from "../genome/rng"; + +export interface MorphParam { + key: string; + label: string; + min: number; + max: number; + step?: number; + /** Gaussian std-dev per breeding step (in the parameter's own units). 0 = fixed gene. */ + sigma: number; +} + +// Ranges mirror Wild7-tweak3.json's slider_ranges. Sensor angle/distance carry the largest sigmas +// — they're the sensitive shape controls the user wants to evolve; the rest drift gently, and +// depositGain stays fixed for now (it's an energy knob; jittering it risks dead candidates). +export const MORPH_PARAMS = [ + { key: "sensorGain", label: "Sensor Gain", min: 0, max: 10, sigma: 0.25 }, + { key: "sensorAngle", label: "Sensor Angle", min: -1, max: 1, sigma: 0.08 }, + { key: "sensorDistance", label: "Sensor Distance", min: 0, max: 3, sigma: 0.15 }, + { key: "globalForceMult", label: "Global Force Mult", min: 0, max: 2, sigma: 0.05 }, + { key: "strafePower", label: "Strafe Power", min: 0, max: 0.5, sigma: 0.02 }, + { key: "axialForce", label: "Axial Force", min: -1, max: 1, sigma: 0.05 }, + { key: "lateralForce", label: "Lateral Force", min: -1, max: 1, sigma: 0.05 }, + { key: "depositGain", label: "Deposit Gain", min: 0, max: 300, step: 1, sigma: 0 }, +] as const satisfies readonly MorphParam[]; + +export type MorphKey = (typeof MORPH_PARAMS)[number]["key"]; + +/** The organism morphology vector — exactly the keys in MORPH_PARAMS. */ +export type Morph = Record; + +const clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi : x); + +/** A child morphology: the parent's, with each non-fixed gene jittered and clamped to range. */ +export function mutateMorph(parent: Morph, rng: Rng): Morph { + const out = { ...parent }; + for (const p of MORPH_PARAMS) { + if (p.sigma > 0) out[p.key] = clamp(parent[p.key] + rng.normal(0, p.sigma), p.min, p.max); + } + return out; +} diff --git a/src/state/generation.svelte.ts b/src/state/generation.svelte.ts index 73d5f41..4eeb05e 100644 --- a/src/state/generation.svelte.ts +++ b/src/state/generation.svelte.ts @@ -9,20 +9,27 @@ import type { CPPN } from "../genome/cppn"; import { compileGenome } from "../genome/compile"; +import { Rng } from "../genome/rng"; import { randomGenomes, spawnChildren } from "../evolution/spawn"; +import { MORPH_PARAMS, mutateMorph, type Morph } from "../evolution/morph"; import type { Rule } from "../engine/rule"; +import type { SubstrateParams } from "../engine/params"; import type { GenSpec } from "../engine/Engine"; import { activeGenome } from "./genome.svelte"; -import { substrateParams } from "./substrate.svelte"; +import { environment } from "./environment.svelte"; import { organism } from "./organism.svelte"; const SIZE = 9; const COLS = 3; -// Non-reactive: the candidate genomes and their compiled rules for the current generation. +// Non-reactive: the candidate genomes, their compiled rules, and their morphology vectors. +// (Plain data, but kept beside the CPPNs out of $state for consistency — generationSpecs() pulls +// them when the nonce changes.) let memberCppns: CPPN[] = []; let memberRules: Rule[] = []; +let memberMorphs: Morph[] = []; let randomSeed = 1; // hops each time we spawn a fresh (parentless) generation +const morphRng = new Rng(1337); // independent stream for morphology jitter export const generation = $state({ nonce: 0, // bump -> orchestrator pushes generationSpecs() into the engine @@ -31,7 +38,32 @@ export const generation = $state({ building: false, // true while the engine warms up + captures thumbnails }); -/** Build a new generation: children of `parents`, or a diverse random spread if null. */ +/** Snapshot the live organism store as a morphology vector (the base for the next generation). */ +function currentMorph(): Morph { + const m = {} as Morph; + for (const p of MORPH_PARAMS) m[p.key] = (organism as Record)[p.key]; + return m; +} + +/** Fold a candidate's morphology with the shared dish into the engine's flat SubstrateParams. + * (depositGain rides separately on GenSpec; environment is the shared substrate.) */ +function paramsFrom(m: Morph): SubstrateParams { + return { + sensorGain: m.sensorGain, + sensorAngle: m.sensorAngle, + sensorDistance: m.sensorDistance, + globalForceMult: m.globalForceMult, + strafePower: m.strafePower, + axialForce: m.axialForce, + lateralForce: m.lateralForce, + drag: environment.drag, + trailPersistence: environment.trailPersistence, + trailDiffusion: environment.trailDiffusion, + }; +} + +/** Build a new generation: children of `parents`, or a diverse random spread if null. Each + * candidate's morphology is the current organism's, jittered (sensor angle/distance most). */ export function newGeneration(parents: CPPN[] | null): void { if (parents && parents.length) { memberCppns = spawnChildren(parents, SIZE); @@ -40,6 +72,8 @@ export function newGeneration(parents: CPPN[] | null): void { randomSeed = (Math.imul(randomSeed, 1664525) + 1013904223) >>> 0; } memberRules = memberCppns.map((c) => compileGenome(c).rule); + const base = currentMorph(); + memberMorphs = memberCppns.map(() => mutateMorph(base, morphRng)); generation.building = true; generation.nonce++; } @@ -54,11 +88,19 @@ export function memberCppn(i: number) { return memberCppns[i].toJSON(); } -/** The engine's view of the generation: one spec per candidate (shared substrate, varying rule). */ +/** The candidate at `i`'s evolved morphology (loaded into the organism store on focus). */ +export function memberMorph(i: number): Morph { + return memberMorphs[i]; +} + +/** The engine's view of the generation: one spec per candidate (shared dish, per-candidate rule + * AND morphology). The engine already takes per-tile params, so this needs no engine change. */ export function generationSpecs(): { specs: GenSpec[]; cols: number } { - const params = substrateParams(); - const depositGain = organism.depositGain; - const specs = memberRules.map((rule) => ({ rule, params, depositGain })); + const specs = memberRules.map((rule, i) => ({ + rule, + params: paramsFrom(memberMorphs[i]), + depositGain: memberMorphs[i].depositGain, + })); return { specs, cols: COLS }; } diff --git a/src/ui/Controls.svelte b/src/ui/Controls.svelte index 437b566..bda08ba 100644 --- a/src/ui/Controls.svelte +++ b/src/ui/Controls.svelte @@ -5,19 +5,13 @@ import { organism, ORGANISM_DEFAULTS } from "../state/organism.svelte"; import { environment, ENVIRONMENT_DEFAULTS } from "../state/environment.svelte"; import { view, VIEW_DEFAULTS } from "../state/view.svelte"; + import { MORPH_PARAMS } from "../evolution/morph"; type Slider = { key: keyof T; label: string; min: number; max: number; step?: number }; - const ORGANISM_SLIDERS: Slider[] = [ - { key: "sensorGain", label: "Sensor Gain", min: 0, max: 10 }, - { key: "sensorAngle", label: "Sensor Angle", min: -1, max: 1 }, - { key: "sensorDistance", label: "Sensor Distance", min: 0, max: 3 }, - { key: "globalForceMult", label: "Global Force Mult", min: 0, max: 2 }, - { key: "strafePower", label: "Strafe Power", min: 0, max: 0.5 }, - { key: "axialForce", label: "Axial Force", min: -1, max: 1 }, - { key: "lateralForce", label: "Lateral Force", min: -1, max: 1 }, - { key: "depositGain", label: "Deposit Gain", min: 0, max: 300, step: 1 }, - ]; + // Organism sliders come from the single morphology source of truth (evolution/morph.ts), so the + // editor ranges and the breeder's mutation always agree on the same parameter set. + const ORGANISM_SLIDERS = MORPH_PARAMS as readonly Slider[]; const ENVIRONMENT_SLIDERS: Slider[] = [ { key: "drag", label: "Drag (viscosity)", min: -1, max: 1 },