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} +
+
+ +