From b810fb386b431cc1de8e020bf0f3916650af7109 Mon Sep 17 00:00:00 2001 From: "oscillatory.net" Date: Tue, 2 Jun 2026 21:13:46 -0500 Subject: [PATCH] WIP: Finish M2, additional reorg in data and UI (mid-M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus 4.8 says: The split. The flat inspector store is gone, replaced by three ontology-grouped stores that mirror the record schema: - state/organism.svelte.ts — morphology (sensor gain/angle/distance, force mult, strafe, axial/lateral, deposit gain) - state/environment.svelte.ts — the shared dish (drag, trail persistence, trail diffusion) - state/view.svelte.ts — display-only (exposure, debug) + the restart signal - state/substrate.svelte.ts — the orchestrator fold: assembles the engine's flat SubstrateParams from organism+environment (the engine boundary is unchanged — it doesn't care about the ontology, it just needs the 10 params). Controls.svelte now shows three labeled sections (Organism · Environment · View), and breeder.svelte.ts's record mapping is nearly 1:1 instead of un-flattening a bag. dragCoefficient is deliberately not a live slider yet — it stays 1.0 in saved records until the Stokes product is plumbed into the shader (so the env drag slider is the only drag knob for now). Reset restores Wild7 defaults per group; the engine wasn't touched. Persistence: yes, in-memory only, and yes it's planned. MemoryRepository is a heap Map — every refresh wipes it. The fix is LocalRepository on IndexedDB behind the same Repository interface, so save/load/lineage code above it doesn't change at all. Given it already cost you organisms, I'd pull it forward rather than wait. Milestones remaining (recap): the real Picbreeder breeder grid (thumbnails of a population → select → breed) + thumbnail rendering, IndexedDB persistence, the DAG lineage graph view you asked for, viability filtering (auto-reject duds), and all of atproto (OAuth + publish). We're roughly mid-M3. --- src/App.svelte | 51 ++++- src/engine/Engine.ts | 10 + src/genome/compile.ts | 295 ++++++++++++++++++++++++ src/genome/cppn.ts | 337 ++++++++++++++++++++++++++++ src/genome/rng.ts | 55 +++++ src/persistence/MemoryRepository.ts | 51 +++++ src/persistence/Repository.ts | 24 ++ src/persistence/record.ts | 119 ++++++++++ src/state/breeder.svelte.ts | 168 ++++++++++++++ src/state/environment.svelte.ts | 14 ++ src/state/genome.svelte.ts | 103 +++++++++ src/state/inspector.svelte.ts | 13 -- src/state/organism.svelte.ts | 22 ++ src/state/substrate.svelte.ts | 22 ++ src/state/view.svelte.ts | 10 + src/ui/Controls.svelte | 79 ++++--- src/ui/GenomePanel.svelte | 81 +++++++ src/ui/LineagePanel.svelte | 117 ++++++++++ 18 files changed, 1511 insertions(+), 60 deletions(-) create mode 100644 src/genome/compile.ts create mode 100644 src/genome/cppn.ts create mode 100644 src/genome/rng.ts create mode 100644 src/persistence/MemoryRepository.ts create mode 100644 src/persistence/Repository.ts create mode 100644 src/persistence/record.ts create mode 100644 src/state/breeder.svelte.ts create mode 100644 src/state/environment.svelte.ts create mode 100644 src/state/genome.svelte.ts delete mode 100644 src/state/inspector.svelte.ts create mode 100644 src/state/organism.svelte.ts create mode 100644 src/state/substrate.svelte.ts create mode 100644 src/state/view.svelte.ts create mode 100644 src/ui/GenomePanel.svelte create mode 100644 src/ui/LineagePanel.svelte diff --git a/src/App.svelte b/src/App.svelte index 077964b..24e6e8b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,30 +1,40 @@
@@ -59,6 +84,8 @@ {#if error}

Engine failed to start: {error}

{:else} + + {/if}
diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 4a34100..fb66ae2 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -133,6 +133,16 @@ export class Engine { this.exposure = e; } + /** Repro-relevant engine settings as plain data (for the SimPins of a saved record). */ + config(): { seed: number; warmup: number; fieldRes: number; particleCount: number } { + return { + seed: this.seed, + warmup: this.warmupSteps, + fieldRes: this.fieldRes, + particleCount: this.particleCount, + }; + } + setStepsPerFrame(n: number) { this.stepsPerFrame = Math.max(1, Math.floor(n)); } diff --git a/src/genome/compile.ts b/src/genome/compile.ts new file mode 100644 index 0000000..20690f1 --- /dev/null +++ b/src/genome/compile.ts @@ -0,0 +1,295 @@ +// Translation option 2: compile a CPPN into a Fluoddity Rule, then fold that losslessly into +// the 244-float plane-wave rule the engine renders. Port of reference/fourier_rule.py plus the +// pack step from docs/generalized_engine_spec.md / cppn_picbreeder_design.md §5. +// +// Why random-feature *selection* (not fixed random frequencies): 10 random sinusoids span a +// tiny subspace of a 4D->4D function, capturing almost nothing. Instead: +// 1. draw a large candidate pool of low-frequency-biased random frequencies, +// 2. fit sin+cos amplitudes for the whole pool by linear least squares, +// 3. keep the N_CENTERS candidates carrying the most output energy, +// 4. refit amplitudes on the selected centers only. +// The result is a genuine N_CENTERS Rule whose dynamics track the CPPN. +// +// The clean sin/cos+DC basis here is exactly the minisim-style subset of the engine's +// generalized plane-wave basis, so packPlaneWave() maps it in with NO loss (the only residual +// error is the band-limited CPPN->Fourier projection itself, intrinsic to the 10-center fit). +// +// Pure TS, no WebGL — but it imports the engine's flat Rule *type* (rule.ts is plain data). + +import { CPPN } from "./cppn"; +import { Rng } from "./rng"; +import { toRule, RULE_FLOATS, type Rule } from "../engine/rule"; + +export const N_CENTERS = 10; +export const N_IN = 4; // (L.x, L.y, R.x, R.y) +export const N_OUT = 4; // (force.x, force.y, strafe.x, strafe.y) + +/** A compiled Rule in the shared-frequency sin/cos + DC basis (cf. fourier_rule.Rule). */ +export interface CompiledRule { + frequency: number[][]; // N_CENTERS × 4 + ampSin: number[][]; // N_CENTERS × N_OUT + ampCos: number[][]; // N_CENTERS × N_OUT + dc: number[]; // N_OUT +} + +export interface CompileOptions { + /** RNG seed for both the sample set and the candidate frequency pool. */ + seed?: number; + /** number of (L,R) samples to fit against. */ + nSamples?: number; + /** std-dev of the gaussian sample domain. */ + domain?: number; + /** size of the candidate frequency pool to select from. */ + pool?: number; +} + +// Defaults tuned DOWN from the Python reference (4096 samples / 256 pool) so the compile stays +// interactive in the browser (~a few hundred ms, runs on genome change, not per frame). The +// algorithm is identical; only the fit-set size differs. +const DEFAULTS = { seed: 7, nSamples: 1536, domain: 2.0, pool: 192 } as const; + +/** Low-frequency-biased random frequency vectors, like Fluoddity. */ +function candidateFrequencies(seed: number, pool: number): number[][] { + const rng = new Rng(seed); + const cand: number[][] = new Array(pool); + for (let p = 0; p < pool; p++) { + const scale = 1.0 + 2.0 * rng.random() ** 2; // bias toward low frequencies + cand[p] = [ + (rng.random() * 2 - 1) * scale, + (rng.random() * 2 - 1) * scale, + (rng.random() * 2 - 1) * scale, + (rng.random() * 2 - 1) * scale, + ]; + } + return cand; +} + +/** + * Fit a CompiledRule to a CPPN. Returns { rule, relError } where relError is the relative L2 + * fit error over the sample set (0 = perfect, 1 = captured nothing). + */ +export function compileCppn(net: CPPN, opts: CompileOptions = {}): { rule: CompiledRule; relError: number } { + const seed = opts.seed ?? DEFAULTS.seed; + const nSamples = opts.nSamples ?? DEFAULTS.nSamples; + const domain = opts.domain ?? DEFAULTS.domain; + const pool = opts.pool ?? DEFAULTS.pool; + + const rng = new Rng(seed); + const X: number[][] = new Array(nSamples); + for (let s = 0; s < nSamples; s++) { + const row = new Array(N_IN); + for (let k = 0; k < N_IN; k++) row[k] = rng.normal(0, domain); + X[s] = row; + } + const Y = net.eval(X); // nSamples × N_OUT + + const cand = candidateFrequencies(seed, pool); // pool × 4 + + // Full design matrix Phi = [sin(X·candᵀ) | cos(X·candᵀ) | 1], shape nSamples × (2*pool + 1). + const m = 2 * pool + 1; + const Phi = buildDesign(X, cand); + const coef = lstsq(Phi, Y, m, N_OUT); // (2*pool+1) × N_OUT + + // Rank candidates by total output energy; keep the strongest N_CENTERS. + const energy = new Array(pool); + for (let p = 0; p < pool; p++) { + let e = 0; + for (let k = 0; k < N_OUT; k++) { + const s = coef[p][k]; + const c = coef[pool + p][k]; + e += s * s + c * c; + } + energy[p] = e; + } + const keep = energy + .map((e, p) => [e, p] as const) + .sort((a, b) => b[0] - a[0]) + .slice(0, N_CENTERS) + .map(([, p]) => p); + + const frequency = keep.map((p) => cand[p]); + + // Refit amplitudes on the selected centers only (DC included). + const mk = 2 * N_CENTERS + 1; + const PhiK = buildDesign(X, frequency); + const coefK = lstsq(PhiK, Y, mk, N_OUT); + + const rule: CompiledRule = { + frequency, + ampSin: coefK.slice(0, N_CENTERS), + ampCos: coefK.slice(N_CENTERS, 2 * N_CENTERS), + dc: coefK[2 * N_CENTERS], + }; + + const Yhat = evalRule(rule, X); + let num = 0; + let den = 0; + for (let s = 0; s < nSamples; s++) { + for (let k = 0; k < N_OUT; k++) { + const d = Yhat[s][k] - Y[s][k]; + num += d * d; + den += Y[s][k] * Y[s][k]; + } + } + const relError = Math.sqrt(num) / (Math.sqrt(den) + 1e-9); + return { rule, relError }; +} + +/** Build [sin(X·freqᵀ) | cos(X·freqᵀ) | 1] as nSamples rows of length 2*nFreq + 1. */ +function buildDesign(X: number[][], freq: number[][]): Float64Array[] { + const n = X.length; + const nf = freq.length; + const m = 2 * nf + 1; + const Phi: Float64Array[] = new Array(n); + for (let s = 0; s < n; s++) { + const x = X[s]; + const row = new Float64Array(m); + for (let p = 0; p < nf; p++) { + const f = freq[p]; + const proj = x[0] * f[0] + x[1] * f[1] + x[2] * f[2] + x[3] * f[3]; + row[p] = Math.sin(proj); + row[nf + p] = Math.cos(proj); + } + row[2 * nf] = 1.0; // DC column + Phi[s] = row; + } + return Phi; +} + +/** Evaluate a CompiledRule (what the engine does). inputs: N × 4. */ +export function evalRule(rule: CompiledRule, inputs: number[][]): number[][] { + const N = inputs.length; + const out: number[][] = new Array(N); + for (let s = 0; s < N; s++) { + const x = inputs[s]; + const r = rule.dc.slice(); + for (let i = 0; i < N_CENTERS; i++) { + const f = rule.frequency[i]; + const proj = x[0] * f[0] + x[1] * f[1] + x[2] * f[2] + x[3] * f[3]; + const sn = Math.sin(proj); + const cs = Math.cos(proj); + const as = rule.ampSin[i]; + const ac = rule.ampCos[i]; + for (let k = 0; k < N_OUT; k++) r[k] += as[k] * sn + ac[k] * cs; + } + out[s] = r; + } + return out; +} + +// --------------------------------------------------------------------------- +// Linear least squares via ridge-regularized normal equations. +// numpy uses SVD; we use (PhiᵀPhi + λI) coef = PhiᵀY solved by Gauss–Jordan. The tiny ridge +// guards against the near-collinear candidate frequencies (normal equations square the +// condition number); λ is scaled to the matrix so it stays negligible for well-posed fits. +// --------------------------------------------------------------------------- +function lstsq(Phi: Float64Array[], Y: number[][], m: number, c: number): number[][] { + const n = Phi.length; + const A: Float64Array[] = Array.from({ length: m }, () => new Float64Array(m)); + const B: Float64Array[] = Array.from({ length: m }, () => new Float64Array(c)); + + // Accumulate the upper triangle of A = PhiᵀPhi and B = PhiᵀY. + for (let s = 0; s < n; s++) { + const row = Phi[s]; + const yr = Y[s]; + for (let i = 0; i < m; i++) { + const ri = row[i]; + if (ri === 0) continue; + const Ai = A[i]; + for (let j = i; j < m; j++) Ai[j] += ri * row[j]; + const Bi = B[i]; + for (let k = 0; k < c; k++) Bi[k] += ri * yr[k]; + } + } + + // Mirror to the lower triangle and add the ridge. + let tr = 0; + for (let i = 0; i < m; i++) tr += A[i][i]; + const ridge = 1e-8 * (tr / m + 1e-12); + for (let i = 0; i < m; i++) { + A[i][i] += ridge; + for (let j = i + 1; j < m; j++) A[j][i] = A[i][j]; + } + + return solveSystem(A, B, m, c); +} + +/** Gauss–Jordan elimination with partial pivoting; B holds c right-hand-side columns. */ +function solveSystem(A: Float64Array[], B: Float64Array[], m: number, c: number): number[][] { + for (let col = 0; col < m; col++) { + let piv = col; + let max = Math.abs(A[col][col]); + for (let r = col + 1; r < m; r++) { + const v = Math.abs(A[r][col]); + if (v > max) { + max = v; + piv = r; + } + } + if (piv !== col) { + [A[piv], A[col]] = [A[col], A[piv]]; + [B[piv], B[col]] = [B[col], B[piv]]; + } + const Ap = A[col]; + const d = Ap[col]; + if (Math.abs(d) < 1e-12) continue; // singular column — ridge should keep this from happening + for (let r = 0; r < m; r++) { + if (r === col) continue; + const f = A[r][col] / d; + if (f === 0) continue; + const Ar = A[r]; + for (let k = col; k < m; k++) Ar[k] -= f * Ap[k]; + const Br = B[r]; + const Bp = B[col]; + for (let k = 0; k < c; k++) Br[k] -= f * Bp[k]; + } + } + + const out: number[][] = new Array(m); + for (let i = 0; i < m; i++) { + const d = A[i][i]; + const row = new Array(c); + const Bi = B[i]; + const inv = Math.abs(d) < 1e-12 ? 0 : 1 / d; + for (let k = 0; k < c; k++) row[k] = Bi[k] * inv; + out[i] = row; + } + return out; +} + +// --------------------------------------------------------------------------- +// Lossless fold into the 244-float plane-wave rule (rule.ts layout). For each center i and +// channel k: freq[k] = the center's shared 4D frequency, and the sin/cos amplitudes become +// amp·cos(·+phase) via the identity a·sin θ + b·cos θ = √(a²+b²)·cos(θ + atan2(−a, b)). +// dc carries straight through. See docs/generalized_engine_spec.md (pack_planewave). +// --------------------------------------------------------------------------- +export function packPlaneWave(rule: CompiledRule): Rule { + const out = new Float32Array(RULE_FLOATS); + for (let i = 0; i < N_CENTERS; i++) { + const base = i * 24; + const f = rule.frequency[i]; + // freq[0..3]: the same 4D frequency vector for all four output channels. + for (let k = 0; k < 4; k++) { + const o = base + k * 4; + out[o] = f[0]; + out[o + 1] = f[1]; + out[o + 2] = f[2]; + out[o + 3] = f[3]; + } + // amp (base+16..19) and phase (base+20..23), per channel. + for (let k = 0; k < 4; k++) { + const s = rule.ampSin[i][k]; + const cc = rule.ampCos[i][k]; + out[base + 16 + k] = Math.hypot(s, cc); + out[base + 20 + k] = Math.atan2(-s, cc); + } + } + for (let k = 0; k < 4; k++) out[240 + k] = rule.dc[k]; + return toRule(out); +} + +/** Convenience: CPPN -> 244-float engine rule, with the fit error. */ +export function compileGenome(net: CPPN, opts?: CompileOptions): { rule: Rule; relError: number } { + const { rule, relError } = compileCppn(net, opts); + return { rule: packPlaneWave(rule), relError }; +} diff --git a/src/genome/cppn.ts b/src/genome/cppn.ts new file mode 100644 index 0000000..d5c8ee8 --- /dev/null +++ b/src/genome/cppn.ts @@ -0,0 +1,337 @@ +// CPPN genotype — port of reference/cppn.py. +// +// A Compositional Pattern Producing Network: a small DAG of typed-activation nodes that maps a +// fixed-length input vector to a fixed-length output vector by function composition. Evolved +// with NEAT-lite operators (perturb weight, add connection, add node, mutate activation, +// crossover). This is the GENOME (docs/cppn_picbreeder_design.md, locus A): it encodes +// Fluoddity's Rule, mapping a 4D sensor reading (L, R) -> 4D (force, strafe). compile.ts +// compiles it to the 244-float plane-wave rule the engine renders. +// +// Pure TS, no WebGL (architecture boundary): mutation/crossover operate on the graph, never on +// the flat 244-float vector. + +import { Rng } from "./rng"; + +// The "canonical functions" the CPPN paper composes: identity, periodic (segmentation), +// symmetric (bilateral frames), bounded. All elementwise. +export const ACTIVATIONS = { + identity: (x: number) => x, + sin: (x: number) => Math.sin(x), + gauss: (x: number) => Math.exp(-(x * x)), // symmetric -> bilateral frames + abs: (x: number) => Math.abs(x), // symmetric, kinked + tanh: (x: number) => Math.tanh(x), // bounded +} as const; + +export type Activation = keyof typeof ACTIVATIONS; +export const ACTIVATION_NAMES = Object.keys(ACTIVATIONS) as Activation[]; + +export type NodeKind = "input" | "hidden" | "output"; + +export interface CppnNode { + id: number; + kind: NodeKind; + activation: Activation; // ignored for inputs + bias: number; +} + +export interface Connection { + src: number; + dst: number; + weight: number; + enabled: boolean; +} + +export interface CppnDict { + nIn: number; + nOut: number; + nextId: number; + nodes: CppnNode[]; + conns: Connection[]; +} + +export interface MutateOptions { + pWeight?: number; + weightSigma?: number; + pAddConn?: number; + pAddNode?: number; + pActivation?: number; + pBias?: number; +} + +export class CPPN { + readonly nIn: number; + readonly nOut: number; + /** Insertion order is preserved (Map) — inputs first, so input_ids stay 0..nIn-1. */ + readonly nodes: Map; + conns: Connection[]; + private nextId: number; + rng: Rng; + private orderCache: number[] | null = null; + + constructor( + nIn: number, + nOut: number, + nodes: CppnNode[], + conns: Connection[], + nextId: number, + rng: Rng, + ) { + this.nIn = nIn; + this.nOut = nOut; + this.nodes = new Map(nodes.map((n) => [n.id, n])); + this.conns = conns; + this.nextId = nextId; + this.rng = rng; + } + + /** Minimal genome: inputs + outputs, fully connected, no hidden (cf. cppn.py __init__). */ + static minimal(nIn: number, nOut: number, rng: Rng): CPPN { + const nodes: CppnNode[] = []; + for (let i = 0; i < nIn; i++) { + nodes.push({ id: i, kind: "input", activation: "identity", bias: 0 }); + } + for (let j = 0; j < nOut; j++) { + nodes.push({ id: nIn + j, kind: "output", activation: rng.pick(ACTIVATION_NAMES), bias: 0 }); + } + const conns: Connection[] = []; + for (let i = 0; i < nIn; i++) { + for (let j = 0; j < nOut; j++) { + conns.push({ src: i, dst: nIn + j, weight: rng.normal(0, 1.0), enabled: true }); + } + } + return new CPPN(nIn, nOut, nodes, conns, nIn + nOut, rng); + } + + // -- ids ------------------------------------------------------------------- + private inputIds(): number[] { + const ids: number[] = []; + for (const n of this.nodes.values()) if (n.kind === "input") ids.push(n.id); + return ids; + } + + private outputIds(): number[] { + const ids: number[] = []; + for (const n of this.nodes.values()) if (n.kind === "output") ids.push(n.id); + return ids.sort((a, b) => a - b); + } + + // -- evaluation ------------------------------------------------------------ + /** Kahn's algorithm over enabled connections. */ + private topoOrder(): number[] { + if (this.orderCache) return this.orderCache; + const incoming = new Map(); + const adj = new Map(); + for (const nid of this.nodes.keys()) { + incoming.set(nid, 0); + adj.set(nid, []); + } + for (const c of this.conns) { + if (!c.enabled) continue; + adj.get(c.src)!.push(c.dst); + incoming.set(c.dst, incoming.get(c.dst)! + 1); + } + const queue: number[] = []; + for (const [nid, d] of incoming) if (d === 0) queue.push(nid); + const order: number[] = []; + while (queue.length) { + const nid = queue.pop()!; + order.push(nid); + for (const dst of adj.get(nid)!) { + const d = incoming.get(dst)! - 1; + incoming.set(dst, d); + if (d === 0) queue.push(dst); + } + } + this.orderCache = order; + return order; + } + + /** + * Evaluate the network on a batch. X is N rows of length nIn; returns N rows of length nOut. + * Vectorized per node (a column array across the batch), mirroring the numpy reference. + */ + eval(X: number[][]): number[][] { + const N = X.length; + const vals = new Map(); + const inIds = this.inputIds(); + for (let k = 0; k < inIds.length; k++) { + const col = new Float64Array(N); + for (let s = 0; s < N; s++) col[s] = X[s][k]; + vals.set(inIds[k], col); + } + + const incoming = new Map(); + for (const nid of this.nodes.keys()) incoming.set(nid, []); + for (const c of this.conns) if (c.enabled) incoming.get(c.dst)!.push(c); + + for (const nid of this.topoOrder()) { + const node = this.nodes.get(nid)!; + if (node.kind === "input") continue; + const acc = new Float64Array(N); + acc.fill(node.bias); + for (const c of incoming.get(nid)!) { + const src = vals.get(c.src); + if (!src) continue; + const w = c.weight; + for (let s = 0; s < N; s++) acc[s] += w * src[s]; + } + const fn = ACTIVATIONS[node.activation]; + for (let s = 0; s < N; s++) acc[s] = fn(acc[s]); + vals.set(nid, acc); + } + + const outIds = this.outputIds(); + const out: number[][] = new Array(N); + for (let s = 0; s < N; s++) { + const row = new Array(outIds.length); + for (let j = 0; j < outIds.length; j++) { + const col = vals.get(outIds[j]); + row[j] = col ? col[s] : 0; + } + out[s] = row; + } + return out; + } + + // -- mutation (NEAT-lite) -------------------------------------------------- + private invalidate() { + this.orderCache = null; + } + + /** Would adding src->dst create a cycle? True if dst can already reach src. */ + private createsCycle(src: number, dst: number): boolean { + const adj = new Map(); + for (const nid of this.nodes.keys()) adj.set(nid, []); + for (const c of this.conns) if (c.enabled) adj.get(c.src)!.push(c.dst); + const stack = [dst]; + const seen = new Set(); + while (stack.length) { + const n = stack.pop()!; + if (n === src) return true; + for (const m of adj.get(n)!) { + if (!seen.has(m)) { + seen.add(m); + stack.push(m); + } + } + } + return false; + } + + /** Return a mutated copy (defaults mirror cppn.py). */ + mutate(opts: MutateOptions = {}): CPPN { + const { + pWeight = 0.8, + weightSigma = 0.5, + pAddConn = 0.15, + pAddNode = 0.08, + pActivation = 0.1, + pBias = 0.2, + } = opts; + + const clone = this.copy(); + const r = clone.rng; + + // perturb weights + for (const c of clone.conns) { + if (c.enabled && r.random() < pWeight) c.weight += r.normal(0, weightSigma); + } + + // perturb biases / activations on non-input nodes + for (const n of clone.nodes.values()) { + if (n.kind === "input") continue; + if (r.random() < pBias) n.bias += r.normal(0, 0.3); + if (r.random() < pActivation) n.activation = r.pick(ACTIVATION_NAMES); + } + + if (r.random() < pAddConn) clone.tryAddConnection(); + if (r.random() < pAddNode) clone.tryAddNode(); + + clone.invalidate(); + return clone; + } + + private tryAddConnection() { + const r = this.rng; + const candidates = [...this.nodes.values()]; + for (let attempt = 0; attempt < 20; attempt++) { + const src = r.pick(candidates); + const dst = r.pick(candidates); + if (dst.kind === "input" || src.id === dst.id) continue; + if (this.conns.some((c) => c.src === src.id && c.dst === dst.id)) continue; + if (this.createsCycle(src.id, dst.id)) continue; + this.conns.push({ src: src.id, dst: dst.id, weight: r.normal(0, 1.0), enabled: true }); + return; + } + } + + private tryAddNode() { + const r = this.rng; + const enabled = this.conns.filter((c) => c.enabled); + if (!enabled.length) return; + const c = enabled[r.int(enabled.length)]; + c.enabled = false; + const newId = this.nextId++; + this.nodes.set(newId, { id: newId, kind: "hidden", activation: r.pick(ACTIVATION_NAMES), bias: 0 }); + // src -> new (weight 1) -> dst (original weight): preserves the function at insertion. + this.conns.push({ src: c.src, dst: newId, weight: 1.0, enabled: true }); + this.conns.push({ src: newId, dst: c.dst, weight: c.weight, enabled: true }); + } + + // -- crossover ------------------------------------------------------------- + /** Take this genome's structure; average shared connection weights with `other`. */ + crossover(other: CPPN): CPPN { + const child = this.copy(); + const otherW = new Map(); + for (const c of other.conns) if (c.enabled) otherW.set(`${c.src},${c.dst}`, c.weight); + for (const c of child.conns) { + const key = `${c.src},${c.dst}`; + const w = otherW.get(key); + if (w !== undefined && this.rng.random() < 0.5) c.weight = 0.5 * (c.weight + w); + } + child.invalidate(); + return child; + } + + // -- (de)serialization ----------------------------------------------------- + /** A copy that SHARES the rng (matches cppn.py: parent and clone advance one stream). */ + copy(): CPPN { + return new CPPN( + this.nIn, + this.nOut, + [...this.nodes.values()].map((n) => ({ ...n })), + this.conns.map((c) => ({ ...c })), + this.nextId, + this.rng, + ); + } + + toJSON(): CppnDict { + return { + nIn: this.nIn, + nOut: this.nOut, + nextId: this.nextId, + nodes: [...this.nodes.values()].map((n) => ({ ...n })), + conns: this.conns.map((c) => ({ ...c })), + }; + } + + static fromJSON(d: CppnDict, rng?: Rng): CPPN { + return new CPPN( + d.nIn, + d.nOut, + d.nodes.map((n) => ({ ...n })), + d.conns.map((c) => ({ ...c })), + d.nextId, + rng ?? new Rng(0), + ); + } + + /** [hiddenNodeCount, enabledConnectionCount]. */ + complexity(): [number, number] { + let hidden = 0; + for (const n of this.nodes.values()) if (n.kind === "hidden") hidden++; + const enabled = this.conns.reduce((acc, c) => acc + (c.enabled ? 1 : 0), 0); + return [hidden, enabled]; + } +} diff --git a/src/genome/rng.ts b/src/genome/rng.ts new file mode 100644 index 0000000..395613f --- /dev/null +++ b/src/genome/rng.ts @@ -0,0 +1,55 @@ +// Deterministic seeded RNG for the genome layer (pure TS, no WebGL). Covers the slice of +// numpy.random.Generator that the Python reference (cppn.py / fourier_rule.py) leans on: +// random() / normal() / integers() / choice(). +// +// The stream does NOT reproduce numpy's PCG64 byte-for-byte — and it doesn't need to. +// Genomes are generated and evolved in TS now; reproducibility of a *phenotype* is pinned by +// the engine seed plus the serialized genome, not by matching Python's RNG. What matters here +// is that a given seed yields the same genome/compile every time. Uses the same mulberry32 +// core as the engine's particle init (Engine.ts) for consistency. + +export class Rng { + private a: number; + private spare: number | null = null; + + constructor(seed: number) { + this.a = seed >>> 0; + } + + /** uniform [0, 1) — mulberry32. */ + random(): number { + let a = (this.a = (this.a + 0x6d2b79f5) | 0); + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + /** uniform [lo, hi). */ + uniform(lo: number, hi: number): number { + return lo + (hi - lo) * this.random(); + } + + /** Gaussian via Box–Muller, caching the second variate. */ + normal(mu = 0, sigma = 1): number { + if (this.spare !== null) { + const z = this.spare; + this.spare = null; + return mu + sigma * z; + } + const u = Math.max(this.random(), 1e-12); + const v = this.random(); + const r = Math.sqrt(-2 * Math.log(u)); + this.spare = r * Math.sin(2 * Math.PI * v); + return mu + sigma * (r * Math.cos(2 * Math.PI * v)); + } + + /** integer in [0, n). */ + int(n: number): number { + return Math.floor(this.random() * n); + } + + /** uniformly chosen element of arr. */ + pick(arr: readonly T[]): T { + return arr[this.int(arr.length)]; + } +} diff --git a/src/persistence/MemoryRepository.ts b/src/persistence/MemoryRepository.ts new file mode 100644 index 0000000..ec5a334 --- /dev/null +++ b/src/persistence/MemoryRepository.ts @@ -0,0 +1,51 @@ +// In-memory Repository — a Map, nothing more. The point is to settle the record shape and the +// lineage queries with zero persistence friction before IndexedDB / atproto. Same interface, so +// those drop in later without touching callers. + +import type { Repository, StoredRecord } from "./Repository"; +import type { OrganismRecord } from "./record"; + +export class MemoryRepository implements Repository { + private store = new Map(); + private seq = 0; + + async save(record: OrganismRecord): Promise { + const id = `org-${(++this.seq).toString(36)}-${Date.now().toString(36)}`; + this.store.set(id, record); + return id; + } + + async get(id: string): Promise { + return this.store.get(id); + } + + async list(): Promise { + return [...this.store.entries()].map(([id, record]) => ({ id, record })); + } + + async children(id: string): Promise { + return [...this.store.entries()] + .filter(([, record]) => record.parents.includes(id)) + .map(([cid, record]) => ({ id: cid, record })); + } + + async ancestry(id: string): Promise { + // BFS up the DAG; dedupe (a node can be reached via two parents), nearest-first. + const out: StoredRecord[] = []; + const seen = new Set(); + let frontier = this.store.get(id)?.parents ?? []; + while (frontier.length) { + const next: string[] = []; + for (const pid of frontier) { + if (seen.has(pid)) continue; + seen.add(pid); + const record = this.store.get(pid); + if (!record) continue; + out.push({ id: pid, record }); + next.push(...record.parents); + } + frontier = next; + } + return out; + } +} diff --git a/src/persistence/Repository.ts b/src/persistence/Repository.ts new file mode 100644 index 0000000..f3f34d1 --- /dev/null +++ b/src/persistence/Repository.ts @@ -0,0 +1,24 @@ +// The persistence seam. The UI + evolution layers depend only on this interface, so the +// in-memory store (now), IndexedDB (offline v1), and atproto (v2) are interchangeable. +// +// Lineage is a DAG (crossover -> multiple parents). It is pointer-based: a record stores only +// its `parents`, and the graph is reconstructed by traversal — `children` (downward) and +// `ancestry` (upward). Records are never rewritten, so parent ids stay stable. + +import type { OrganismRecord } from "./record"; + +export interface StoredRecord { + id: string; + record: OrganismRecord; +} + +export interface Repository { + /** Persist a record; returns its freshly assigned id. */ + save(record: OrganismRecord): Promise; + get(id: string): Promise; + list(): Promise; + /** Records that name `id` among their parents (one step down the DAG). */ + children(id: string): Promise; + /** All ancestors of `id` (full upward DAG closure), nearest-first, de-duplicated. */ + ancestry(id: string): Promise; +} diff --git a/src/persistence/record.ts b/src/persistence/record.ts new file mode 100644 index 0000000..6dcd430 --- /dev/null +++ b/src/persistence/record.ts @@ -0,0 +1,119 @@ +// The organism record — the thing we save, version, and (later) publish to atproto. Persistence +// is abstracted behind Repository; this file is just the typed shape, deliberately settled +// against an in-memory store before any network format work. +// +// Four levels, kept separate on purpose (see the design discussion / docs/web_architecture.md §7): +// - GENOTYPE : genome.cppn — the bred truth. +// - ORGANISM : genome.cppn (compiled -> rule) + genome.morphology — the heritable creature. +// - ENVIRONMENT : the shared "dish". Multiple organisms in one world share it. +// - SIM (repro pins): what the stochastic render needs to reproduce the phenotype. +// Plus VIEW (display-only, NOT needed to reproduce dynamics) and lineage (a DAG of parents). + +import type { CppnDict } from "../genome/cppn"; + +export const SCHEMA_VERSION = 0; +/** v0 substrate semantics (docs/v0_normalization.md). A separate axis from schemaVersion. */ +export const ENGINE_VERSION = 0; +/** Deterministic render length for a reproducible capture; the live view ignores it. */ +export const RENDER_STEPS = 200; + +/** + * Scalar body-plan genes the CPPN doesn't (yet) encode. Heritable in principle; whether each + * actually mutates is governed by a separate sigma table in the evolution layer, NOT stored + * here. `dragCoefficient` is dimensionless (default 1.0) and multiplies the environment's + * damping: effective_retention = 1 - (1 - environment.drag) * dragCoefficient. + */ +export interface Morphology { + sensorGain: number; + sensorAngle: number; + sensorDistance: number; + strafePower: number; + axialForce: number; + lateralForce: number; + globalForceMult: number; + depositGain: number; // pheromone strength — affects DYNAMICS (in the field feedback loop) + dragCoefficient: number; // default 1.0; the agent half of Stokes drag (medium half is environment.drag) +} + +/** The heritable organism. cppn is truth; the compiled 244-float rule is a cache (see rule?). */ +export interface Genome { + cppn: CppnDict; + morphology: Morphology; +} + +/** The shared substrate — one per world; multiple organisms reference/share it. */ +export interface Environment { + trailPersistence: number; + trailDiffusion: number; + drag: number; // medium-viscosity baseline (the μ half of Stokes drag) + boundary: "wrap"; // reserved: "bounce" | "reset" + worldSize: number; // 1 in v0 +} + +/** Reproducibility pins — the phenotype is a stochastic sim, so "same genome -> same render" + * needs these (docs/cppn_picbreeder_design.md §6). */ +export interface SimPins { + seed: number; + warmup: number; + steps: number; + fieldRes: number; + particleCount: number; + init: "uniform"; // reserved: "ring" | "grid" +} + +/** Display-only. Affects the picture, never the dynamics; not required to reproduce. */ +export interface ViewSettings { + exposure: number; +} + +/** How an organism came to be — sets the parents arity (DAG). */ +export type Origin = "root" | "mutate" | "cross"; + +export interface OrganismRecord { + schemaVersion: number; + engineVersion: number; + createdAt: string; // ISO 8601 + name?: string; + + // Lineage — a DAG (crossover gives multiple parents). Pointer-based: the tree/graph is + // reconstructed by traversal (Repository.children / .ancestry), never embedded/rewritten. + parents: string[]; // [] root · [a] mutation · [a,b,…] crossover + origin: Origin; + depth: number; // longest path to a root = max(parent depths) + 1 (convenience; reconstructable) + + genome: Genome; + environment: Environment; + sim: SimPins; + view?: ViewSettings; + rule?: number[]; // 244-float compile cache — regenerable from genome.cppn, stored for fast load/feeds +} + +export interface RecordParts { + cppn: CppnDict; + morphology: Morphology; + environment: Environment; + sim: SimPins; + view?: ViewSettings; + rule?: number[]; + parents: string[]; + origin: Origin; + depth: number; + name?: string; +} + +export function buildOrganismRecord(p: RecordParts): OrganismRecord { + return { + schemaVersion: SCHEMA_VERSION, + engineVersion: ENGINE_VERSION, + createdAt: new Date().toISOString(), + name: p.name, + parents: p.parents, + origin: p.origin, + depth: p.depth, + genome: { cppn: p.cppn, morphology: p.morphology }, + environment: p.environment, + sim: p.sim, + view: p.view, + rule: p.rule, + }; +} diff --git a/src/state/breeder.svelte.ts b/src/state/breeder.svelte.ts new file mode 100644 index 0000000..e1519c7 --- /dev/null +++ b/src/state/breeder.svelte.ts @@ -0,0 +1,168 @@ +// Breeder / lineage state — save the live organism, load saved ones, track what the live genome +// descends from so saves stamp the right DAG parents. Backed by an in-memory Repository for now. +// +// Reactive layer holds only plain data (saved-list summaries, current parent refs, sim pins). +// The record assembly reads the grouped live stores (organism + environment + view + genome) — +// the mapping into the layered record shape (record.ts) is now nearly 1:1. + +import { MemoryRepository } from "../persistence/MemoryRepository"; +import type { Repository } from "../persistence/Repository"; +import { + buildOrganismRecord, + RENDER_STEPS, + type Environment, + type Morphology, + type OrganismRecord, + type Origin, + type SimPins, + type ViewSettings, +} from "../persistence/record"; +import { organism } from "./organism.svelte"; +import { environment } from "./environment.svelte"; +import { view } from "./view.svelte"; +import { activeGenome, compileActive, setGenome } from "./genome.svelte"; + +const repo: Repository = new MemoryRepository(); + +interface ParentRef { + id: string; + depth: number; +} + +/** A compact summary of a stored record for list/tree rendering. */ +interface SavedSummary { + id: string; + name: string; + origin: Origin; + depth: number; + parents: string[]; +} + +function defaultSim(): SimPins { + return { seed: 1, warmup: 80, steps: RENDER_STEPS, fieldRes: 1024, particleCount: 512 * 512, init: "uniform" }; +} + +export const breeder = $state({ + saved: [] as SavedSummary[], + currentParents: [] as ParentRef[], // what the live genome descends from ([] = a fresh root) + selectedId: null as string | null, + sim: defaultSim(), +}); + +/** Adopt the engine's real repro settings (seed/warmup/resolution/count) for saved records. */ +export function setSimPins(cfg: { seed: number; warmup: number; fieldRes: number; particleCount: number }) { + breeder.sim = { ...cfg, steps: RENDER_STEPS, init: "uniform" }; +} + +// --- live state -> record fragments (the flat-to-layered mapping) ------------------------------- + +function morphologyNow(): Morphology { + return { + sensorGain: organism.sensorGain, + sensorAngle: organism.sensorAngle, + sensorDistance: organism.sensorDistance, + strafePower: organism.strafePower, + axialForce: organism.axialForce, + lateralForce: organism.lateralForce, + globalForceMult: organism.globalForceMult, + depositGain: organism.depositGain, + dragCoefficient: 1.0, // not a live knob yet; default keeps effective drag == environment.drag + }; +} + +function environmentNow(): Environment { + return { + trailPersistence: environment.trailPersistence, + trailDiffusion: environment.trailDiffusion, + drag: environment.drag, + boundary: "wrap", + worldSize: 1, + }; +} + +function viewNow(): ViewSettings { + return { exposure: view.exposure }; +} + +// --- actions ----------------------------------------------------------------------------------- + +async function refreshList() { + const all = await repo.list(); + breeder.saved = all + .map(({ id, record }) => ({ + id, + name: record.name ?? id, + origin: record.origin, + depth: record.depth, + parents: record.parents, + })) + .sort((a, b) => a.depth - b.depth || a.id.localeCompare(b.id)); +} + +/** Save the live organism as a child of the current lineage context. */ +export async function saveCurrent(name?: string): Promise { + const parents = breeder.currentParents.map((p) => p.id); + const origin: Origin = parents.length === 0 ? "root" : parents.length === 1 ? "mutate" : "cross"; + const depth = breeder.currentParents.length + ? Math.max(...breeder.currentParents.map((p) => p.depth)) + 1 + : 0; + + const record = buildOrganismRecord({ + cppn: activeGenome().toJSON(), + morphology: morphologyNow(), + environment: environmentNow(), + sim: { ...breeder.sim }, + view: viewNow(), + rule: Array.from(compileActive()), // cache the 244-float compile + parents, + origin, + depth, + name: name?.trim() || undefined, + }); + + const id = await repo.save(record); + // The just-saved organism becomes the basis for further exploration (next save is its child). + breeder.currentParents = [{ id, depth }]; + breeder.selectedId = id; + await refreshList(); + return id; +} + +/** Load a saved organism into the live view and make it the current lineage context. */ +export async function loadOrganism(id: string): Promise { + const record = await repo.get(id); + if (!record) return; + applyRecord(record); + breeder.currentParents = [{ id, depth: record.depth }]; + breeder.selectedId = id; +} + +/** Start a fresh lineage: the next save will be a root, not a child of anything. */ +export function newLineage(): void { + breeder.currentParents = []; + breeder.selectedId = null; +} + +function applyRecord(r: OrganismRecord): void { + const m = r.genome.morphology; + const e = r.environment; + Object.assign(organism, { + sensorGain: m.sensorGain, + sensorAngle: m.sensorAngle, + sensorDistance: m.sensorDistance, + strafePower: m.strafePower, + axialForce: m.axialForce, + lateralForce: m.lateralForce, + globalForceMult: m.globalForceMult, + depositGain: m.depositGain, + }); + Object.assign(environment, { + trailPersistence: e.trailPersistence, + trailDiffusion: e.trailDiffusion, + drag: e.drag, + }); + if (r.view) view.exposure = r.view.exposure; + // setGenome bumps the genome nonce -> App recompiles the rule and restarts the sim. The cached + // record.rule is intentionally ignored: recompiling from the cppn is canonical and consistent. + setGenome(r.genome.cppn); +} diff --git a/src/state/environment.svelte.ts b/src/state/environment.svelte.ts new file mode 100644 index 0000000..5733a93 --- /dev/null +++ b/src/state/environment.svelte.ts @@ -0,0 +1,14 @@ +// Live ENVIRONMENT (substrate / "dish") state — the knobs shared by every organism in a world. +// trailPersistence + trailDiffusion act on the shared field, not per-particle; drag is the medium- +// viscosity baseline (the μ half of Stokes drag; the organism's dragCoefficient is the other half). +// boundary (wrap) and worldSize (1) are fixed in v0, so they aren't live knobs. + +import { WILD7_PARAMS } from "../engine/golden"; + +export const ENVIRONMENT_DEFAULTS = { + drag: WILD7_PARAMS.drag, + trailPersistence: WILD7_PARAMS.trailPersistence, + trailDiffusion: WILD7_PARAMS.trailDiffusion, +}; + +export const environment = $state({ ...ENVIRONMENT_DEFAULTS }); diff --git a/src/state/genome.svelte.ts b/src/state/genome.svelte.ts new file mode 100644 index 0000000..87b0188 --- /dev/null +++ b/src/state/genome.svelte.ts @@ -0,0 +1,103 @@ +// The active-genome reactive state for the M2 "compile active genome -> live canvas" loop. +// +// ARCHITECTURE: Svelte's reactive layer holds only plain serializable data. The live CPPN +// instance has Maps inside and is compiled imperatively, so it lives in a NON-reactive module +// holder — never in $state (the same discipline that keeps GL objects out of reactivity). The +// reactive `genome` store carries a `nonce` the orchestrator watches, plus display-only stats. + +import { CPPN, type CppnDict } from "../genome/cppn"; +import { Rng } from "../genome/rng"; +import { compileGenome, N_IN, N_OUT, type CompileOptions } from "../genome/compile"; +import type { Rule } from "../engine/rule"; + +// A minimal CPPN compiles to near-trivial (often dead) dynamics, so seed each fresh genome +// with a handful of mutations — enough structure to render something on first view. +const INITIAL_MUTATIONS = 20; +const COMPILE_OPTS: CompileOptions = {}; // defaults (see compile.ts) + +function freshGenome(seed: number): CPPN { + const rng = new Rng(seed); + let net = CPPN.minimal(N_IN, N_OUT, rng); + for (let i = 0; i < INITIAL_MUTATIONS; i++) net = net.mutate(); + return net; +} + +let current = freshGenome(1); +let lineageSeed = 1; + +export const genome = $state({ + nonce: 0, // bump on any genome change -> orchestrator recompiles + restarts the sim + generation: 0, // mutation depth since the last fresh/random genome + seed: 1, // lineage seed (changes on Randomize) + hidden: 0, // hidden-node count + connections: 0, // enabled-connection count + fitError: 0, // last compile relative-L2 error (0 = perfect, 1 = captured nothing) + useAnchor: false, // bypass the genome and render the Wild7 anchor (substrate sanity check) +}); + +function syncStats() { + const [h, c] = current.complexity(); + genome.hidden = h; + genome.connections = c; +} +syncStats(); + +/** The live (non-reactive) genome instance. */ +export function activeGenome(): CPPN { + return current; +} + +/** Compile the active genome to a 244-float rule, recording the fit error for display. */ +export function compileActive(): Rule { + const { rule, relError } = compileGenome(current, COMPILE_OPTS); + genome.fitError = relError; + return rule; +} + +function bump() { + syncStats(); + genome.nonce++; +} + +/** One NEAT-lite mutation step down the current lineage. */ +export function mutateGenome() { + current = current.mutate(); + genome.generation++; + bump(); +} + +/** Start a brand-new random lineage. */ +export function randomizeGenome() { + lineageSeed = (Math.imul(lineageSeed, 1664525) + 1013904223) >>> 0; + current = freshGenome(lineageSeed); + genome.seed = lineageSeed; + genome.generation = 0; + bump(); +} + +/** Back to the deterministic starting genome (seed 1). */ +export function resetGenome() { + current = freshGenome(1); + lineageSeed = 1; + genome.seed = 1; + genome.generation = 0; + bump(); +} + +/** + * Replace the active genome with a loaded one (e.g. from a saved record). Seeds a fresh rng so + * subsequent mutations diverge; seed/generation become plain display counters (the genome is no + * longer reconstructable from them — it's loaded data, not a seed+depth coordinate). + */ +export function setGenome(cppn: CppnDict) { + lineageSeed = (Math.imul(lineageSeed, 1664525) + 1013904223) >>> 0; + current = CPPN.fromJSON(cppn, new Rng(lineageSeed)); + genome.seed = lineageSeed; + genome.generation = 0; + bump(); +} + +/** Serialized genome (for inspection / future persistence). */ +export function genomeJSON(): string { + return JSON.stringify(current.toJSON()); +} diff --git a/src/state/inspector.svelte.ts b/src/state/inspector.svelte.ts deleted file mode 100644 index 02b3b1b..0000000 --- a/src/state/inspector.svelte.ts +++ /dev/null @@ -1,13 +0,0 @@ -// The live-view reactive state: the 10 v0 substrate params plus the spike's display knobs. -// This is the ONLY reactive layer — plain serializable data (never GL objects), which the -// orchestrator snapshots into the imperative Engine. Seeded from the Wild7 anchor. - -import { WILD7_PARAMS } from "../engine/golden"; - -export const inspector = $state({ - params: { ...WILD7_PARAMS }, - depositGain: 30, // behavioral energy knob (see docs §0) - exposure: 200, // HSV brightness scale (no readback auto-exposure: Firefox blocks it) - debug: false, // diagnostic display (red=NaN, gradient=zero, log-magnitude) - restartNonce: 0, // bump to re-seed + clear the sim -}); diff --git a/src/state/organism.svelte.ts b/src/state/organism.svelte.ts new file mode 100644 index 0000000..9df061a --- /dev/null +++ b/src/state/organism.svelte.ts @@ -0,0 +1,22 @@ +// Live ORGANISM (morphology) state — the scalar body-plan knobs that belong to the creature, not +// the dish (see [[data-model-layering]] / docs). Reactive plain data; the orchestrator folds it +// (with the environment) into the engine's flat SubstrateParams. `depositGain` lives here because +// it's the organism's pheromone strength (and it affects dynamics, not just the picture). +// +// NB: `dragCoefficient` is a morphology gene in the record but is NOT a live knob yet — it stays +// at 1.0 (so effective drag == environment.drag) until the Stokes product is plumbed into the engine. + +import { WILD7_PARAMS } from "../engine/golden"; + +export const ORGANISM_DEFAULTS = { + sensorGain: WILD7_PARAMS.sensorGain, + sensorAngle: WILD7_PARAMS.sensorAngle, + sensorDistance: WILD7_PARAMS.sensorDistance, + globalForceMult: WILD7_PARAMS.globalForceMult, + strafePower: WILD7_PARAMS.strafePower, + axialForce: WILD7_PARAMS.axialForce, + lateralForce: WILD7_PARAMS.lateralForce, + depositGain: 30, // behavioral energy knob (see docs §0) +}; + +export const organism = $state({ ...ORGANISM_DEFAULTS }); diff --git a/src/state/substrate.svelte.ts b/src/state/substrate.svelte.ts new file mode 100644 index 0000000..9ccbe0b --- /dev/null +++ b/src/state/substrate.svelte.ts @@ -0,0 +1,22 @@ +// The orchestrator's fold: the engine wants one flat SubstrateParams (all 10 dynamics params), +// not the organism/environment ontology. Assemble a plain snapshot from the two grouped stores. +// Called inside App's $effect, so the reads here register as reactive dependencies. + +import type { SubstrateParams } from "../engine/params"; +import { organism } from "./organism.svelte"; +import { environment } from "./environment.svelte"; + +export function substrateParams(): SubstrateParams { + return { + sensorGain: organism.sensorGain, + sensorAngle: organism.sensorAngle, + sensorDistance: organism.sensorDistance, + globalForceMult: organism.globalForceMult, + drag: environment.drag, + strafePower: organism.strafePower, + axialForce: organism.axialForce, + lateralForce: organism.lateralForce, + trailPersistence: environment.trailPersistence, + trailDiffusion: environment.trailDiffusion, + }; +} diff --git a/src/state/view.svelte.ts b/src/state/view.svelte.ts new file mode 100644 index 0000000..af45271 --- /dev/null +++ b/src/state/view.svelte.ts @@ -0,0 +1,10 @@ +// Live VIEW state — display-only knobs that change the picture, never the dynamics, so they're not +// part of reproduction. `restartNonce` is a control signal (bump to re-seed + clear the sim); it's +// kept out of VIEW_DEFAULTS because Reset shouldn't trigger a restart. + +export const VIEW_DEFAULTS = { + exposure: 200, // HSV brightness scale (no readback auto-exposure: Firefox blocks it) + debug: false, // diagnostic display (red=NaN, gradient=zero, log-magnitude) +}; + +export const view = $state({ ...VIEW_DEFAULTS, restartNonce: 0 }); diff --git a/src/ui/Controls.svelte b/src/ui/Controls.svelte index d434ff1..437b566 100644 --- a/src/ui/Controls.svelte +++ b/src/ui/Controls.svelte @@ -1,66 +1,72 @@
-
Wild7 — v0 substrate
- - - - {#each PARAM_SLIDERS as s (s.key)} +
Organism — morphology
+ {#each ORGANISM_SLIDERS as s (s.key)} {/each} -
+
Environment — substrate
+ {#each ENVIRONMENT_SLIDERS as s (s.key)} + + {/each} -
@@ -80,13 +86,16 @@ color: #ddd; backdrop-filter: blur(6px); } - .title { font-size: 12px; opacity: 0.7; margin-bottom: 0.5rem; } + .group { + font-size: 12px; opacity: 0.7; margin: 0.6rem 0 0.35rem; + padding-bottom: 0.2rem; border-bottom: 1px solid rgba(255, 255, 255, 0.12); + } + .group:first-child { margin-top: 0; } .row { display: flex; align-items: center; gap: 0.5rem; margin: 0.25rem 0; } - .toggle { gap: 0.4rem; margin-bottom: 0.5rem; cursor: pointer; } + .toggle { gap: 0.4rem; cursor: pointer; } .name { flex: 0 0 8.5rem; opacity: 0.85; } .val { flex: 0 0 3.5rem; text-align: right; font-variant-numeric: tabular-nums; opacity: 0.7; } input[type="range"] { flex: 1 1 auto; min-width: 0; } - .sep { height: 1px; background: rgba(255, 255, 255, 0.12); margin: 0.6rem 0; } .buttons { gap: 0.4rem; margin-top: 0.6rem; } button { flex: 1; padding: 0.35rem; font: inherit; color: #ddd; cursor: pointer; diff --git a/src/ui/GenomePanel.svelte b/src/ui/GenomePanel.svelte new file mode 100644 index 0000000..f59d14b --- /dev/null +++ b/src/ui/GenomePanel.svelte @@ -0,0 +1,81 @@ + + +
+
Genome — CPPN (locus A)
+ + + +
+
generation{genome.generation}
+
seed{genome.seed}
+
hidden nodes{genome.hidden}
+
connections{genome.connections}
+
fit error{genome.fitError.toFixed(3)}
+
+ +
+ + + +
+ +

Mutate and watch — each step recompiles the rule and restarts the sim.

+
+ + diff --git a/src/ui/LineagePanel.svelte b/src/ui/LineagePanel.svelte new file mode 100644 index 0000000..e42364c --- /dev/null +++ b/src/ui/LineagePanel.svelte @@ -0,0 +1,117 @@ + + +
+
Lineage — in-memory store
+ +
+ {#if parentSummaries.length === 0} + fresh lineage — next save is a root + {:else} + descends from + {#each parentSummaries as p (p.id)} + {p.name} + {/each} + {/if} +
+ +
+ + +
+ +
+ {#if breeder.saved.length === 0} +
nothing saved yet
+ {:else} + {#each breeder.saved as s (s.id)} + + {/each} + {/if} +
+
+ + -- 2.51.2