From 764dc96dcd512e6e2727487b07a642ee6d1bf54d Mon Sep 17 00:00:00 2001 From: Bryant Ejorh <191651927+CodeByBryant@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:24:44 -0700 Subject: [PATCH] test(genetics): Phase 4 exit-criteria sweep and long-run hardening Phase 4 PR 5, test-only. Closes the phase by checking every roadmap exit criterion end to end on a real running world, and by hardening the long-run determinism suite for genomes and lineage. phase4-exit-criteria.test.ts audits births by stepping a world tick by tick and remembering every living genome from the previous tick, so each child's parents are known exactly as they were at conception even if they die later. Over 6,000 ticks it checks, per birth: scalar genes lie within the blend of the parents' values plus mutation; brain weights come from both parents; the parents' architecture and weight count are kept; the genome is valid; the child is closer to each parent than the parents are to each other, and its brain far closer than unrelated founders' brains are; roughly 95% of its weights are its parents' own and essentially none match a freshly drawn brain; and lineage records survive parent death. The learning-inheritance mode is configurable, accepts all three modes, rejects others, and produces three different results. Long-run suite additions: chunked and direct runs now also match on the lineage store (which is deliberately not hashed); genomes stay valid and every trait mean stays within 0.35 of its range width of baseline over 100,000 ticks (measured worst case on two seeds: 0.20); genomes stay valid under ten times the default mutation rate and strength; and after extinction the whole lineage stays queryable and does not change over 80,000 more ticks. docs/genetics/genome.md gains a map from each guarantee to the test that checks it. Co-Authored-By: Claude Sonnet 5.5 --- docs/genetics/genome.md | 19 ++ .../tests/determinism/long-run.test.ts | 110 +++++++- .../tests/phase4-exit-criteria.test.ts | 236 ++++++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 packages/simulation/tests/phase4-exit-criteria.test.ts diff --git a/docs/genetics/genome.md b/docs/genetics/genome.md index 1838f6b..07cf3b2 100644 --- a/docs/genetics/genome.md +++ b/docs/genetics/genome.md @@ -164,3 +164,22 @@ genetics: { ``` plus `organisms.hue` (founder baseline), `reproduction.offspringInvestment` and `reproduction.overheadFraction`, which replace `reproduction.energyCost` and `reproduction.offspringEnergy`. + +## Verification + +Where each guarantee is checked (all paths under `packages/simulation/tests/`): + +| Guarantee | Checked in | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| Mutation is bounded; parents are never modified | `genetics/mutation.test.ts`, `genetics/recombination.test.ts` | +| Both parents contribute (genes and brain weights) | `genetics/recombination.test.ts`; end to end in `phase4-exit-criteria.test.ts` | +| Integer, boolean, circular and correlated genes behave | `genetics/mutation.test.ts`, `genetics/recombination.test.ts` (synthetic tables) | +| Brain weights preserve the architecture | `genetics/recombination.test.ts`, `phase4-exit-criteria.test.ts` | +| Learning parameters mutate; all three inheritance modes | `genetics/mutation.test.ts`, `genetics/learning-inheritance.test.ts` | +| Genome validation, repair and comparison | `genetics/validation.test.ts`, `genetics/distance.test.ts` | +| Lineage queries and survival after death | `lineage.test.ts`, `world-genomes.test.ts`, `phase4-exit-criteria.test.ts` | +| Systems read the organism's own genes | `genes-in-systems.test.ts`, `traits.test.ts` | +| Mutation off reproduces Phase 3; mutation on changes the run | `world-genomes.test.ts` (Scenario A: 26 / 47 / 21 with mutation off) | +| Genomes valid and trait drift bounded over 100,000 ticks | `determinism/long-run.test.ts` | +| Chunked and stepped runs match, lineage included | `determinism/long-run.test.ts` | +| Lineage stays queryable and frozen after extinction | `determinism/long-run.test.ts` | diff --git a/packages/simulation/tests/determinism/long-run.test.ts b/packages/simulation/tests/determinism/long-run.test.ts index 527a722..b029b24 100644 --- a/packages/simulation/tests/determinism/long-run.test.ts +++ b/packages/simulation/tests/determinism/long-run.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { World } from '../../src/index' +import { World, buildGeneSpecs, genomeToVector } from '../../src/index' /** * The roadmap's first-milestone check, at full scale: a seeded world can run 100,000 ticks @@ -13,6 +13,11 @@ import { World } from '../../src/index' * `'reproduction'` random stream survive chunk and step boundaries exactly - not just the * fields Phase 2 already exercised. The explicit `reproductionSuccesses` assertions make that * coverage visible rather than incidental. + * + * Since Phase 4 the default configuration also mutates every birth, so the same runs exercise the + * `genetics` stream, genome identity and the lineage store across chunk and step boundaries. The + * lineage store is deliberately not part of the state hash, so its equivalence is asserted + * explicitly rather than inherited from the hash comparison. */ describe('100,000-tick runs', () => { it('runs 100,000 ticks headlessly without ever violating an invariant', () => { @@ -55,6 +60,32 @@ describe('100,000-tick runs', () => { expect(chunked.clock.tick).toBe(direct.clock.tick) expect(chunked.stateHash()).toBe(direct.stateHash()) expect(chunked.snapshot()).toEqual(direct.snapshot()) + + // The lineage store is not hashed, so chunking must be shown not to disturb it directly. + expect(chunked.lineage.size).toBe(direct.lineage.size) + expect(chunked.lineage.size).toBeGreaterThan(chunked.config.organisms.initialCount) + expect(chunked.lineage.founders()).toEqual(direct.lineage.founders()) + const last = chunked.lineage.size - 1 + expect(chunked.lineage.record(last)).toEqual(direct.lineage.record(last)) + expect(chunked.lineage.ancestorsOf(last)).toEqual(direct.lineage.ancestorsOf(last)) + expect(chunked.historicalOrganisms()).toEqual(direct.historicalOrganisms()) + }) + + it('mutation visibly changes genomes over a long run, identically each time', () => { + const run = (): World => { + const world = World.create({ seed: 77, validateEveryTicks: 1000 }) + world.run(30_000) + return world + } + const a = run() + const b = run() + const baseline = buildGeneSpecs(a.config).map((spec) => spec.baseline) + const differing = a + .snapshot() + .organisms.filter((o) => genomeToVector(a.genomeOf(o.id)!).some((v, i) => v !== baseline[i])) + expect(differing.length).toBeGreaterThan(0) + expect(a.stateHash()).toBe(b.stateHash()) + expect(a.lineage.size).toBe(b.lineage.size) }) it('running in many small chunks still matches a single run of the same total', () => { @@ -100,6 +131,41 @@ describe('100,000-tick runs', () => { expect(world.validate()).toEqual({ ok: true, errors: [] }) }) + it('keeps every genome valid and every trait mean near baseline over 100,000 ticks', () => { + // validateEveryTicks runs World.validate(), which checks each living genome against the gene + // table and brain architecture and each lineage record against its genome, every 500 ticks. + const world = World.create({ seed: 1, validateEveryTicks: 500 }) + world.run(100_000) + + const specs = buildGeneSpecs(world.config) + const genomes = world.snapshot().organisms.map((o) => world.genomeOf(o.id)!) + expect(genomes.length).toBeGreaterThan(0) + expect(genomes.length).toBeLessThan(world.config.maxPopulation) + specs.forEach((spec, i) => { + const average = + genomes.reduce((sum, g) => sum + (genomeToVector(g)[i] as number), 0) / genomes.length + // Drift is bounded: no gene's population mean runs off toward an edge of its range + // (measured worst case over 100,000 ticks on two seeds: 0.20 of the range width). + expect(Math.abs(average - spec.baseline) / (spec.max - spec.min)).toBeLessThan(0.35) + }) + // One lineage record per genome ever created: the founders plus every birth. + expect(world.lineage.size).toBe(world.snapshot().metrics.organismsBorn) + expect(world.validate()).toEqual({ ok: true, errors: [] }) + }) + + it('keeps every genome valid under heavy mutation pressure', () => { + // Ten times the default mutation rate and strength is far outside normal use; validity is + // guaranteed by construction (bounded operators), so no invariant may ever fire. + const world = World.create({ + seed: 3, + validateEveryTicks: 250, + config: { genetics: { mutationRateScale: 10, mutationStrengthScale: 10 } } + }) + expect(() => world.run(30_000)).not.toThrow() + expect(world.validate()).toEqual({ ok: true, errors: [] }) + expect(world.lineage.size).toBeGreaterThan(world.config.organisms.initialCount) + }) + it('extinction, once reached, is never reversed across a long remaining run', () => { const world = World.create({ seed: 1, @@ -149,4 +215,46 @@ describe('100,000-tick runs', () => { expect(world.snapshot().organisms).toEqual([]) expect(world.validate()).toEqual({ ok: true, errors: [] }) }) + + it('keeps the whole lineage queryable after the population goes extinct', () => { + const world = World.create({ + seed: 5, + validateEveryTicks: 1000, + config: { + organisms: { initialCount: 15 }, + resources: { initialCount: 80, maxCount: 80, spawnRate: 1 } + } + }) + world.run(20_000) + expect(world.isExtinct).toBe(true) + + const size = world.lineage.size + expect(size).toBeGreaterThan(world.config.organisms.initialCount) + // Every founder lineage has died out, yet every record and ancestry link is still there. + for (const founder of world.lineage.founders()) { + expect(world.lineage.isLineageExtinct(founder)).toBe(true) + expect(world.lineage.record(founder)).toBeDefined() + } + const records = world.historicalOrganisms() + expect(records.length).toBe(size) + for (const record of records) { + const lineage = world.lineage.record(record.genomeId) + expect(lineage).toBeDefined() + for (const parent of lineage!.parentGenomeIds) { + expect(world.lineage.record(parent)).toBeDefined() + } + } + const newest = records[records.length - 1]! + if (newest.parentIds.length > 0) { + expect(world.lineage.ancestorsOf(newest.genomeId).length).toBeGreaterThan(0) + } + + // And extinction adds nothing: the lineage is frozen from here on. + world.run(80_000) + expect(world.lineage.size).toBe(size) + for (const founder of world.lineage.founders()) { + expect(world.lineage.isLineageExtinct(founder)).toBe(true) + } + expect(world.validate()).toEqual({ ok: true, errors: [] }) + }) }) diff --git a/packages/simulation/tests/phase4-exit-criteria.test.ts b/packages/simulation/tests/phase4-exit-criteria.test.ts new file mode 100644 index 0000000..d446b13 --- /dev/null +++ b/packages/simulation/tests/phase4-exit-criteria.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest' +import { resolveWorldConfig } from '@evo/config' +import type { Genome } from '@evo/contracts' +import { World } from '../src/index' +import { genomeDistance } from '../src/genetics/GeneticDistance' +import { buildGeneSpecs } from '../src/genetics/GeneSpecs' +import { createFounderGenome, genomeToVector } from '../src/genetics/GenomeFactory' +import { inheritedBrainWeights } from '../src/genetics/LearningInheritance' +import { validateGenome } from '../src/genetics/GenomeValidation' +import { SeededRandom } from '../src/random/SeededRandom' +import { deriveStreamWords } from '../src/random/streams' + +/** + * The Phase 4 exit criteria (roadmap), each checked end to end on a real running world rather than + * on stand-ins. The last criterion - broad unit test coverage of genome operations - is the + * contents of tests/genetics/, tests/lineage.test.ts and tests/traits.test.ts. + * + * Births are audited by stepping the world one tick at a time and remembering every living genome + * from the previous tick: a child appears with the ids of two parents that were both alive then, + * so the parents' genomes at the moment of conception are known exactly even if they die later. + */ +interface Birth { + child: Genome + mother: Genome + father: Genome +} + +interface Audit { + world: World + births: Birth[] + founders: Genome[] +} + +function auditBirths(ticks: number, seed = 1): Audit { + const world = World.create({ seed }) + const living = (): Map => { + const genomes = new Map() + for (const organism of world.snapshot().organisms) { + genomes.set(organism.genomeId, world.genomeOf(organism.id) as Genome) + } + return genomes + } + let previous = living() + const founders = [...previous.values()] + const births: Birth[] = [] + for (let tick = 0; tick < ticks; tick++) { + world.step() + const current = living() + for (const [id, child] of current) { + if (previous.has(id) || child.parentIds.length !== 2) continue + const mother = previous.get(child.parentIds[0] as number) + const father = previous.get(child.parentIds[1] as number) + if (mother && father) births.push({ child, mother, father }) + } + previous = current + } + return { world, births, founders } +} + +const mean = (values: number[]): number => values.reduce((a, b) => a + b, 0) / values.length + +describe('Phase 4 exit criteria', () => { + const audit = auditBirths(6000) + const { world, births, founders } = audit + const specs = buildGeneSpecs(world.config) + const initialWeightStd = world.config.genetics.brain.initialWeightStd + + it('audits enough births to mean something', () => { + expect(births.length).toBeGreaterThanOrEqual(25) + }) + + describe('offspring inherit traits from both parents', () => { + it('keeps every scalar gene inside the blend of its parents’ values, allowing for mutation', () => { + for (const { child, mother, father } of births) { + const c = genomeToVector(child) + const a = genomeToVector(mother) + const b = genomeToVector(father) + specs.forEach((spec, i) => { + const lo = Math.min(a[i] as number, b[i] as number) + const hi = Math.max(a[i] as number, b[i] as number) + const blend = world.config.genetics.blendAlpha * (hi - lo) + const scale = spec.relative ? hi : spec.max - spec.min + const slack = blend + 6 * spec.mutationStd * scale + expect(c[i]).toBeGreaterThanOrEqual(lo - slack) + expect(c[i]).toBeLessThanOrEqual(hi + slack) + }) + } + }) + + it('draws brain weights from both parents in every birth', () => { + for (const { child, mother, father } of births) { + const w = child.brain.weights + const fromMother = w.some( + (x, i) => x === mother.brain.weights[i] && x !== father.brain.weights[i] + ) + const fromFather = w.some( + (x, i) => x === father.brain.weights[i] && x !== mother.brain.weights[i] + ) + expect(fromMother && fromFather).toBe(true) + } + }) + + it('records both parents’ genomes, in ascending order', () => { + for (const { child, mother, father } of births) { + expect(child.parentIds).toEqual([mother.id, father.id].sort((x, y) => x - y)) + } + }) + }) + + describe('offspring inherit compatible neural weights', () => { + it('keeps the parents’ brain architecture and weight count', () => { + for (const { child, mother, father } of births) { + expect(child.brain.architecture).toEqual(mother.brain.architecture) + expect(child.brain.architecture).toEqual(father.brain.architecture) + expect(child.brain.weights).toHaveLength(mother.brain.weights.length) + } + }) + }) + + describe('mutations remain within valid ranges', () => { + it('produces only valid genomes', () => { + for (const { child } of births) { + expect( + validateGenome(child, specs, { brain: child.brain.architecture, nextGenomeId: 1e9 }) + ).toEqual({ ok: true, errors: [] }) + } + }) + + it('leaves the whole world valid', () => { + expect(world.validate()).toEqual({ ok: true, errors: [] }) + }) + }) + + describe('parent and child genomes can be compared', () => { + const dist = (a: Genome, b: Genome) => genomeDistance(a, b, specs, initialWeightStd) + + it('measures every pair within [0, 1] on every axis', () => { + for (const { child, mother, father } of births) { + for (const parent of [mother, father]) { + for (const value of Object.values(dist(child, parent))) { + expect(value).toBeGreaterThanOrEqual(0) + expect(value).toBeLessThanOrEqual(1) + } + } + } + }) + + it('puts a child closer to each parent than the parents are to each other', () => { + const toMother = mean(births.map((b) => dist(b.child, b.mother).total)) + const toFather = mean(births.map((b) => dist(b.child, b.father).total)) + const between = mean(births.map((b) => dist(b.mother, b.father).total)) + expect(toMother).toBeLessThan(between) + expect(toFather).toBeLessThan(between) + }) + + it('puts a child’s brain far closer to its parents than unrelated founders are to each other', () => { + const parentChild = mean(births.map((b) => dist(b.child, b.mother).brain)) + const strangers: number[] = [] + for (let i = 0; i < founders.length; i++) { + for (let j = i + 1; j < founders.length; j++) { + strangers.push(dist(founders[i] as Genome, founders[j] as Genome).brain) + } + } + expect(parentChild).toBeLessThan(mean(strangers)) + }) + }) + + describe('lineage records survive parent death', () => { + it('keeps the record of a parent that has since died, with its child among its descendants', () => { + const living = new Set(world.snapshot().organisms.map((o) => o.genomeId)) + const orphaned = births.filter((b) => !living.has(b.mother.id)) + expect(orphaned.length).toBeGreaterThan(0) + for (const { child, mother } of orphaned) { + const record = world.lineage.record(mother.id) + expect(record).toBeDefined() + expect(world.lineage.descendantsOf(mother.id)).toContain(child.id) + } + }) + + it('can name every founder and trace the living back to them', () => { + expect(world.lineage.founders()).toEqual(founders.map((g) => g.id)) + }) + }) + + describe('learning inheritance mode is configurable', () => { + it('defaults to partial at 0.15', () => { + expect(world.config.genetics.learningInheritance).toEqual({ mode: 'partial', fraction: 0.15 }) + }) + + it('accepts each mode and rejects anything else', () => { + for (const mode of ['baldwinian', 'partial', 'lamarckian'] as const) { + const config = resolveWorldConfig({ genetics: { learningInheritance: { mode } } }) + expect(config.genetics.learningInheritance.mode).toBe(mode) + expect(() => + World.create({ seed: 1, config: { genetics: { learningInheritance: { mode } } } }) + ).not.toThrow() + } + expect(() => + resolveWorldConfig({ genetics: { learningInheritance: { mode: 'magic' as never } } }) + ).toThrow(/learningInheritance.mode/) + }) + + it('makes the three modes produce three different results', () => { + const genome = [0, 1, 2, 3] + const learned = [4, 1, 0, 11] + const results = (['baldwinian', 'partial', 'lamarckian'] as const).map((mode) => + inheritedBrainWeights(genome, learned, mode, 0.15).join(',') + ) + expect(new Set(results).size).toBe(3) + }) + }) + + describe('reproduction no longer creates unrelated fresh brains', () => { + it('gives a child brain that is overwhelmingly its parents’ own weights', () => { + for (const { child, mother, father } of births) { + const inherited = child.brain.weights.filter( + (x, i) => x === mother.brain.weights[i] || x === father.brain.weights[i] + ).length + expect(inherited / child.brain.weights.length).toBeGreaterThan(0.85) + } + }) + + it('shares essentially nothing with a freshly drawn brain', () => { + const fresh = createFounderGenome( + 0, + specs, + world.config.genetics.brain, + new SeededRandom(deriveStreamWords(999, 'genetics')) + ) + for (const { child } of births) { + const same = child.brain.weights.filter((x, i) => x === fresh.brain.weights[i]).length + expect(same / child.brain.weights.length).toBeLessThan(0.01) + } + }) + }) +})