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