From ffa00349cac133246b322736ffc7245c32f2cb04 Mon Sep 17 00:00:00 2001 From: Bryant Ejorh <191651927+CodeByBryant@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:41:41 -0700 Subject: [PATCH] feat(simulation): organisms carry genomes (behavior-preserving) Phase 4 PR 3. Every organism now owns an immutable genome, and every trait a system reads comes from it instead of global config. Founders get identical genomes built from the config baseline (each with its own random brain from the genetics stream); a child gets a new GenomeId and, for now, a copy of its mother's (the lower-id parent's) genes and brain. Recombination and mutation are switched on in the next PR. Movement, Interaction, Metabolism, Perception, Decision, Death and Reproduction read the organism's own genes. Capability is coupled to cost through derived traits (organisms/traits.ts): energy capacity and basal cost scale with body size, sensor cost with sensor radius, and a parent pays its own investment plus overhead. Reproduction also requires energy >= max(minEnergy gene, cost), so paying can never drive energy negative. At the baseline genome every derived value equals its Phase 3 value exactly. genomeId is added to the snapshot, birth event and history record; World.genomeOf returns the frozen genome; snapshot radius is the organism's own gene. World.validate() now checks every living genome against the gene table and brain architecture and that genome ids are unique. Spatial grid cell sizes become the gene range maximum. DeathSystem and PerceptionSystem lose their now-unused config parameter. Hash schema v3 (nextGenomeId plus each organism's genome); published hash vectors and all golden hashes are regenerated, and stream-usage drops genetics from the reserved list. The golden values change only because the schema did. Equivalence gate: world.test.ts (the pinned Phase 3 scenarios) is byte-identical and passes. Beyond that, 53 full-state digests (every organism's position, energy, age and cooldown, plus the complete event lists) recorded from the pristine Phase 3 engine match the refactored engine exactly across 7 scenarios, including custom baselines, up to 20,000 ticks. Co-Authored-By: Claude Sonnet 5.5 --- .../0006-genetics-and-inheritance.md | 2 +- docs/simulation/determinism.md | 8 +- docs/simulation/update-order.md | 2 +- packages/contracts/src/events.ts | 2 + packages/contracts/src/snapshots.ts | 4 + packages/contracts/tests/contracts.test.ts | 7 +- .../simulation/src/entities/OrganismStore.ts | 4 +- .../simulation/src/genetics/GenomeFactory.ts | 15 + packages/simulation/src/organisms/traits.ts | 43 +++ packages/simulation/src/serialization/hash.ts | 2 +- .../simulation/src/systems/DeathSystem.ts | 8 +- .../simulation/src/systems/DecisionSystem.ts | 2 +- .../src/systems/InteractionSystem.ts | 10 +- .../src/systems/MetabolismSystem.ts | 19 +- .../simulation/src/systems/MovementSystem.ts | 13 +- .../src/systems/PerceptionSystem.ts | 13 +- .../src/systems/ReproductionSystem.ts | 62 ++-- packages/simulation/src/world/World.ts | 65 +++- packages/simulation/src/world/invariants.ts | 34 +- packages/simulation/tests/entities.test.ts | 28 +- .../simulation/tests/genes-in-systems.test.ts | 341 ++++++++++++++++++ .../simulation/tests/golden/state-hashes.json | 16 +- packages/simulation/tests/hash-clock.test.ts | 6 +- packages/simulation/tests/invariants.test.ts | 59 ++- packages/simulation/tests/metrics.test.ts | 8 +- .../simulation/tests/stream-usage.test.ts | 5 +- packages/simulation/tests/support/organism.ts | 64 ++++ packages/simulation/tests/systems.test.ts | 140 +++---- packages/simulation/tests/traits.test.ts | 94 +++++ .../simulation/tests/world-genomes.test.ts | 177 +++++++++ 30 files changed, 1059 insertions(+), 194 deletions(-) create mode 100644 packages/simulation/src/organisms/traits.ts create mode 100644 packages/simulation/tests/genes-in-systems.test.ts create mode 100644 packages/simulation/tests/support/organism.ts create mode 100644 packages/simulation/tests/traits.test.ts create mode 100644 packages/simulation/tests/world-genomes.test.ts diff --git a/docs/decisions/0006-genetics-and-inheritance.md b/docs/decisions/0006-genetics-and-inheritance.md index b36f945..3118aad 100644 --- a/docs/decisions/0006-genetics-and-inheritance.md +++ b/docs/decisions/0006-genetics-and-inheritance.md @@ -99,7 +99,7 @@ Each independent gene (or correlated group) consumes the same draws per birth wh `LineageRecord { genomeId, parentGenomeIds, organismIds, createdAtTick, descendantCount }` (roadmap 4.6) in a new `LineageStore`: registered at birth, marked at death, **never removed**. Queries: parents, ancestors, descendants, most-recent common ancestor, founders, and lineage extinction (no living organism carries the genome or any descendant). Ancestry in a sexual population is a DAG whose ancestor sets grow quickly, so `descendantCount` and the traversal queries are computed **on demand**, not maintained per birth. The store holds only small integer records (growth is O(births)); bounding or pruning it is a Phase 8/9 concern. Like `HistoryStore` it is a derived record and is **not part of the state hash**. It is exposed as `World.lineage`. -`OrganismSnapshot`, `OrganismBornEvent` and `HistoricalOrganismRecord` gain `genomeId`; the full genome is available through `World.genomeOf(organismId)` (a defensive copy), so `snapshot()` stays cheap for the render loop. +`OrganismSnapshot`, `OrganismBornEvent` and `HistoricalOrganismRecord` gain `genomeId`; the full genome is available through `World.genomeOf(organismId)` (genomes are deeply frozen, so the object itself is safe to hold), so `snapshot()` stays cheap for the render loop. ### Learning inheritance diff --git a/docs/simulation/determinism.md b/docs/simulation/determinism.md index 8cc503d..fa97837 100644 --- a/docs/simulation/determinism.md +++ b/docs/simulation/determinism.md @@ -139,7 +139,7 @@ Sections 1-5 and 9-10 are unchanged from version 1. Sections 6 and 8 append new #### Version 3 (current, Phase 4+) -Takes effect in the Phase 4 PR in which organisms start carrying genomes. Sections 1-3, 5 and 7-10 are unchanged from version 2. Sections 4 and 6 append new fields at the end (never inserted or reordered), per ADR 0006: +Introduced in Phase 4 when organisms started carrying genomes. Sections 1-3, 5 and 7-10 are unchanged from version 2. Sections 4 and 6 append new fields at the end (never inserted or reordered), per ADR 0006: | Order | Section | Contents (in this order) | | ----- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -188,9 +188,9 @@ These are computed from the spec above with a reference script and must be repro | Input | `stateHash`-style output | | ---------------------------------------------------------------------------------- | ---------------------------------- | | no bytes | `027ae52ecfc796215593990d4b41437c` | -| header only: `"EVOH"`, `writeUint32(2)` | `dfb410a7fb90a35283f01a1c42a10d0b` | -| header, `writeUint32(7)`, `writeFloat64(-0)`, `writeFloat64(0.5)`, `writeUint8(1)` | `7bc1354f42366f6b62c02fb98d5e2292` | -| same as above with `writeFloat64(+0)` instead of `-0` (proves canonicalization) | `7bc1354f42366f6b62c02fb98d5e2292` | +| header only: `"EVOH"`, `writeUint32(3)` | `6df37fdcc239ba2c992d1d705f53c393` | +| header, `writeUint32(7)`, `writeFloat64(-0)`, `writeFloat64(0.5)`, `writeUint8(1)` | `486214610a2227258a0e30943b304938` | +| same as above with `writeFloat64(+0)` instead of `-0` (proves canonicalization) | `486214610a2227258a0e30943b304938` | **`normal()`** (depends on the engine's `ln`; values are exact binary64 results, shown in shortest round-trip form): diff --git a/docs/simulation/update-order.md b/docs/simulation/update-order.md index e581658..dfa8f3a 100644 --- a/docs/simulation/update-order.md +++ b/docs/simulation/update-order.md @@ -57,7 +57,7 @@ Initial population and resources are created once in `World.create` (setup, not 6. **Movement**: speed bounded by the organism's `maxSpeed` gene (scaled by `juvenileSpeedScale` for juveniles); position clamped inside the world rectangle; the turn actually applied (after clamping to the organism's `maxTurnRate` gene) is recorded as `lastTurnMagnitude` for metabolism to read. 7. **Interaction**: organisms are processed in ascending id; an organism within `radius + resource.radius` of a resource consumes it (`radius` is the organism's gene and the effective `maxEnergy` is derived from it, see docs/genetics/genome.md; both scaled by `juvenileSizeScale` for juveniles); a resource is never consumed twice in a tick; energy gain is capped at the organism's effective `maxEnergy`. 8. **Metabolism**: `cost = (basalCost*sizeFactor + movementCost*speed² + turningCost*lastTurnMagnitude² + sensorCost) * juvenileMetabolicScale (if juvenile, else 1)` per unit time, plus `age += deltaTime`. The formula is explicit and unit-tested (see ADR 0005 for why `learningCost`/`environmentalCost` from the roadmap sketch aren't included yet). -9. **Reproduction**: every organism's `reproductionCooldownRemaining` is first decremented by `deltaTime` (floored at zero); then organisms are processed in ascending id, each considered at most once as an initiator. An organism attempts only once it clears three silent gates (life stage is not `juvenile`, `reproductionCooldownRemaining <= 0`, `energy >=` its own `minEnergy` gene); organisms that don't clear the gates simply don't attempt, no event. An attempting organism is blocked by, in order: the population cap (`organisms.size >= maxPopulation`), then the lack of an eligible, not-yet-paired partner within the initiator's own `searchRadius` gene (a partner must itself clear the three gates by its own genes) - both produce a `reproduction-attempted` event with `succeeded: false`. On success, each parent pays `offspringInvestment * (1 + reproduction.overheadFraction)` and its cooldown resets to its own `cooldown` gene; the child's genome is built from both parents (the lower-id parent is "mother") by recombination then mutation on the `genetics` stream, registered in the lineage store with a new `GenomeId`; the child starts at age 0, `energy = min(investmentA + investmentB, its own derived maxEnergy)`, near the parents' midpoint, with `parentIds` in ascending order. See ADR 0005 for the full algorithm. +9. **Reproduction**: every organism's `reproductionCooldownRemaining` is first decremented by `deltaTime` (floored at zero); then organisms are processed in ascending id, each considered at most once as an initiator. An organism attempts only once it clears three silent gates (life stage is not `juvenile`, `reproductionCooldownRemaining <= 0`, `energy >=` its own `minEnergy` gene); organisms that don't clear the gates simply don't attempt, no event. An attempting organism is blocked by, in order: the population cap (`organisms.size >= maxPopulation`), then the lack of an eligible, not-yet-paired partner within the initiator's own `searchRadius` gene (a partner must itself clear the three gates by its own genes) - both produce a `reproduction-attempted` event with `succeeded: false`. On success, each parent pays `offspringInvestment * (1 + reproduction.overheadFraction)` and its cooldown resets to its own `cooldown` gene; the child's genome gets a new `GenomeId` and is built from both parents (the lower-id parent is "mother"): currently a copy of the mother's genes and brain, with recombination then mutation on the `genetics` stream and lineage registration arriving with the heritable-genomes step of Phase 4; the child starts at age 0, `energy = min(investmentA + investmentB, its own derived maxEnergy)`, near the parents' midpoint, with `parentIds` in ascending order. See ADR 0005 for the full algorithm. 10. **Death**: in ascending id; `energy <= 0` gives `starvation`, `age >= maxAge` (the organism's own gene) gives `age`; the organism is removed from the active store and a compact historical record is appended to the (bounded) history store in the same stage, and its lineage record is marked dead (the record itself is never removed). 11. **Metrics**: counters derived from this tick's events; sets `extinctionTick` the first tick the active population is zero (never cleared - reproduction cannot revive an empty population, since it always requires at least one existing organism as a parent). diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index e822a52..fdffa19 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -1,4 +1,5 @@ import type { EntityId } from './entity' +import type { GenomeId } from './genome' import type { DeathCause } from './world' import type { Vec2 } from './math' @@ -10,6 +11,7 @@ interface BaseEvent { export interface OrganismBornEvent extends BaseEvent { readonly type: 'organism-born' readonly organismId: EntityId + readonly genomeId: GenomeId /** Empty for founders. */ readonly parentIds: readonly EntityId[] readonly position: Readonly diff --git a/packages/contracts/src/snapshots.ts b/packages/contracts/src/snapshots.ts index 8398bd9..3221519 100644 --- a/packages/contracts/src/snapshots.ts +++ b/packages/contracts/src/snapshots.ts @@ -1,10 +1,13 @@ import type { EntityId } from './entity' +import type { GenomeId } from './genome' import type { Vec2 } from './math' import type { DeathCause } from './world' import type { LifeStage } from './organism' export interface OrganismSnapshot { readonly id: EntityId + /** The organism's genome; the full genome is available from `World.genomeOf`. */ + readonly genomeId: GenomeId /** Empty for founders. */ readonly parentIds: readonly EntityId[] readonly position: Readonly @@ -20,6 +23,7 @@ export interface OrganismSnapshot { /** A compact, retained record of an organism after it dies (docs/decisions/0005). */ export interface HistoricalOrganismRecord { readonly id: EntityId + readonly genomeId: GenomeId /** Empty for founders. */ readonly parentIds: readonly EntityId[] readonly birthTick: number diff --git a/packages/contracts/tests/contracts.test.ts b/packages/contracts/tests/contracts.test.ts index c7fdd21..5bdd867 100644 --- a/packages/contracts/tests/contracts.test.ts +++ b/packages/contracts/tests/contracts.test.ts @@ -98,17 +98,20 @@ describe('contracts', () => { it('constructs a HistoricalOrganismRecord', () => { const record: HistoricalOrganismRecord = { id: 5, + genomeId: 9, parentIds: [1, 2], birthTick: 100, deathTick: 200, deathCause: 'starvation' } expect(record.parentIds).toEqual([1, 2]) + expect(record.genomeId).toBe(9) }) - it('OrganismSnapshot has no generation counter, genome, or health/alive field', () => { + it('OrganismSnapshot has no generation counter, embedded genome, or health/alive field', () => { const snapshot: OrganismSnapshot = { id: 1, + genomeId: 1, parentIds: [], position: { x: 0, y: 0 }, velocity: { x: 0, y: 0 }, @@ -120,7 +123,7 @@ describe('contracts', () => { reproductionCooldownRemaining: 0 } const keys = Object.keys(snapshot) - for (const forbidden of ['generation', 'genomeId', 'genome', 'health', 'alive']) { + for (const forbidden of ['generation', 'genome', 'health', 'alive']) { expect(keys).not.toContain(forbidden) } }) diff --git a/packages/simulation/src/entities/OrganismStore.ts b/packages/simulation/src/entities/OrganismStore.ts index 8edf55d..83249a9 100644 --- a/packages/simulation/src/entities/OrganismStore.ts +++ b/packages/simulation/src/entities/OrganismStore.ts @@ -1,4 +1,4 @@ -import type { EntityId } from '@evo/contracts' +import type { EntityId, Genome } from '@evo/contracts' /** Live, mutable organism state. Systems mutate these fields directly through `values()`. */ export interface OrganismState { @@ -11,6 +11,8 @@ export interface OrganismState { age: number energy: number readonly parentIds: readonly EntityId[] + /** Immutable. Every trait a system reads comes from here, not from global config. */ + readonly genome: Genome readonly birthTick: number reproductionCooldownRemaining: number lastTurnMagnitude: number diff --git a/packages/simulation/src/genetics/GenomeFactory.ts b/packages/simulation/src/genetics/GenomeFactory.ts index 8b61bd9..2d2ec07 100644 --- a/packages/simulation/src/genetics/GenomeFactory.ts +++ b/packages/simulation/src/genetics/GenomeFactory.ts @@ -126,3 +126,18 @@ export function createFounderGenome( weights }) } + +/** + * A new genome with the same genes and brain as `source` under a fresh identity. This is how a + * child is made while inheritance is not yet heritable (it copies its "mother", the lower-id + * parent); recombination and mutation replace it without changing the call site's shape. + */ +export function copyGenomeAs(source: Genome, id: GenomeId, parentIds: readonly GenomeId[]): Genome { + return genomeFromParts({ + id, + parentIds, + vector: genomeToVector(source), + architecture: source.brain.architecture, + weights: source.brain.weights + }) +} diff --git a/packages/simulation/src/organisms/traits.ts b/packages/simulation/src/organisms/traits.ts new file mode 100644 index 0000000..d3bb0d4 --- /dev/null +++ b/packages/simulation/src/organisms/traits.ts @@ -0,0 +1,43 @@ +import type { Genome, WorldConfig } from '@evo/contracts' + +/** + * Quantities derived from an organism's genes rather than stored as genes, so that no capability + * is free: a bigger body holds more energy and burns more, and a longer sensor costs more to + * run (docs/decisions/0006). `config.organisms` holds the founder baseline each is scaled from, + * and at the baseline genome every function returns exactly its Phase 3 value (the ratios are + * exactly 1), which is what keeps the genome refactor behavior-preserving. + */ + +/** `(radius / baseRadius)^2`, by multiplication (`**` is banned in the engine). */ +export function sizeFactor(genome: Genome, config: WorldConfig): number { + const ratio = genome.physical.radius / config.organisms.radius + return ratio * ratio +} + +/** Energy capacity of a fully grown organism. */ +export function maxEnergyOf(genome: Genome, config: WorldConfig): number { + return config.organisms.maxEnergy * sizeFactor(genome, config) +} + +/** Energy per second just for being alive; grows with body size. */ +export function basalCostOf(genome: Genome, config: WorldConfig): number { + return genome.metabolic.basalCost * sizeFactor(genome, config) +} + +/** Energy per second for maintaining senses; grows with sensor radius. */ +export function sensorCostOf(genome: Genome, config: WorldConfig): number { + return config.organisms.sensorCost * (genome.sensory.sensorRadius / config.organisms.sensorRadius) +} + +/** What a parent pays for a successful reproduction: its investment plus the transfer overhead. */ +export function reproductionCostOf(genome: Genome, config: WorldConfig): number { + return genome.reproduction.offspringInvestment * (1 + config.reproduction.overheadFraction) +} + +/** + * The energy an organism needs to attempt reproduction: its own `minEnergy` gene, but never less + * than the cost it would pay, so paying can never drive its energy negative. + */ +export function reproductionThresholdOf(genome: Genome, config: WorldConfig): number { + return Math.max(genome.reproduction.minEnergy, reproductionCostOf(genome, config)) +} diff --git a/packages/simulation/src/serialization/hash.ts b/packages/simulation/src/serialization/hash.ts index d21c22f..9789be6 100644 --- a/packages/simulation/src/serialization/hash.ts +++ b/packages/simulation/src/serialization/hash.ts @@ -4,7 +4,7 @@ import { utf8Encode } from './utf8' * Version of the canonical state-hash layout (determinism.md, section 7.3). Any change to the * field list, order, or encoding requires bumping this and regenerating the golden hashes. */ -export const HASH_SCHEMA_VERSION = 2 +export const HASH_SCHEMA_VERSION = 3 const HEADER_MAGIC = 'EVOH' diff --git a/packages/simulation/src/systems/DeathSystem.ts b/packages/simulation/src/systems/DeathSystem.ts index 823b6a4..f929ec5 100644 --- a/packages/simulation/src/systems/DeathSystem.ts +++ b/packages/simulation/src/systems/DeathSystem.ts @@ -1,10 +1,10 @@ -import type { DeathCause, WorldConfig } from '@evo/contracts' +import type { DeathCause } from '@evo/contracts' import type { OrganismStore } from '../entities/OrganismStore' import type { EventLog } from '../events/EventLog' import type { HistoryStore } from '../history/HistoryStore' /** - * Removes organisms that starved (`energy <= 0`) or died of old age (`age >= maxAge`), in + * Removes organisms that starved (`energy <= 0`) or died of old age (`age >=` its own `maxAge` gene), in * ascending id order. This is the only place organisms leave the active store; extinction is * never hidden or silently reversed (docs/simulation/update-order.md, stage 10). Owns the whole * "an organism died" transaction: removal, the historical record, and the event, all in the same @@ -12,7 +12,6 @@ import type { HistoryStore } from '../history/HistoryStore' */ export class DeathSystem { constructor( - private readonly config: WorldConfig, private readonly organisms: OrganismStore, private readonly events: EventLog, private readonly history: HistoryStore @@ -23,7 +22,7 @@ export class DeathSystem { let cause: DeathCause | null = null if (organism.energy <= 0) { cause = 'starvation' - } else if (organism.age >= this.config.organisms.maxAge) { + } else if (organism.age >= organism.genome.physical.maxAge) { cause = 'age' } if (cause === null) continue @@ -31,6 +30,7 @@ export class DeathSystem { this.organisms.remove(organism.id) this.history.append({ id: organism.id, + genomeId: organism.genome.id, parentIds: organism.parentIds, birthTick: organism.birthTick, deathTick: tick, diff --git a/packages/simulation/src/systems/DecisionSystem.ts b/packages/simulation/src/systems/DecisionSystem.ts index d746925..aa1ff40 100644 --- a/packages/simulation/src/systems/DecisionSystem.ts +++ b/packages/simulation/src/systems/DecisionSystem.ts @@ -21,8 +21,8 @@ export class DecisionSystem { update(): void { this.intentBuffer.clear() - const jitter = this.config.organisms.wanderJitter * this.config.timestep for (const organism of this.organisms.values()) { + const jitter = organism.genome.behavioral.wanderJitter * this.config.timestep const perceived = this.perceptionBuffer.get(organism.id) if (perceived) { const desiredHeading = atan2( diff --git a/packages/simulation/src/systems/InteractionSystem.ts b/packages/simulation/src/systems/InteractionSystem.ts index 545c9f6..a38e6ea 100644 --- a/packages/simulation/src/systems/InteractionSystem.ts +++ b/packages/simulation/src/systems/InteractionSystem.ts @@ -3,6 +3,7 @@ import type { OrganismStore } from '../entities/OrganismStore' import type { ResourceState, ResourceStore } from '../entities/ResourceStore' import type { EventLog } from '../events/EventLog' import { lifeStageFor } from '../organisms/lifeStage' +import { maxEnergyOf } from '../organisms/traits' import type { SpatialHash } from '../spatial/SpatialHash' /** @@ -27,15 +28,14 @@ export class InteractionSystem { update(tick: number): void { const consumed = new Set() - const { radius, maxEnergy, juvenileSizeScale, maturityAge, senescenceAge } = - this.config.organisms + const { juvenileSizeScale } = this.config.organisms const resourceRadius = this.config.resources.radius for (const organism of this.organisms.values()) { - const stage = lifeStageFor(organism.age, { maturityAge, senescenceAge }) + const stage = lifeStageFor(organism.age, organism.genome.physical) const sizeScale = stage === 'juvenile' ? juvenileSizeScale : 1 - const captureRadius = radius * sizeScale + resourceRadius - const effectiveMaxEnergy = maxEnergy * sizeScale + const captureRadius = organism.genome.physical.radius * sizeScale + resourceRadius + const effectiveMaxEnergy = maxEnergyOf(organism.genome, this.config) * sizeScale const hits = this.resourceIndex.queryRadius({ x: organism.x, y: organism.y }, captureRadius) const hit = hits.find((candidate) => !consumed.has(candidate.item.id)) diff --git a/packages/simulation/src/systems/MetabolismSystem.ts b/packages/simulation/src/systems/MetabolismSystem.ts index f69bb7d..309102b 100644 --- a/packages/simulation/src/systems/MetabolismSystem.ts +++ b/packages/simulation/src/systems/MetabolismSystem.ts @@ -2,6 +2,7 @@ import type { WorldConfig } from '@evo/contracts' import type { OrganismStore } from '../entities/OrganismStore' import type { EventLog } from '../events/EventLog' import { lifeStageFor } from '../organisms/lifeStage' +import { basalCostOf, sensorCostOf } from '../organisms/traits' /** * Charges the explicit, tested metabolic cost @@ -23,23 +24,19 @@ export class MetabolismSystem { update(tick: number): void { const dt = this.config.timestep - const { - basalCost, - movementCost, - turningCost, - sensorCost, - juvenileMetabolicScale, - maturityAge, - senescenceAge - } = this.config.organisms + const { juvenileMetabolicScale } = this.config.organisms for (const organism of this.organisms.values()) { const speedSquared = organism.vx * organism.vx + organism.vy * organism.vy const turnSquared = organism.lastTurnMagnitude * organism.lastTurnMagnitude - const stage = lifeStageFor(organism.age, { maturityAge, senescenceAge }) + const genome = organism.genome + const stage = lifeStageFor(organism.age, genome.physical) const scale = stage === 'juvenile' ? juvenileMetabolicScale : 1 const cost = - (basalCost + movementCost * speedSquared + turningCost * turnSquared + sensorCost) * + (basalCostOf(genome, this.config) + + genome.metabolic.movementCost * speedSquared + + genome.metabolic.turningCost * turnSquared + + sensorCostOf(genome, this.config)) * scale * dt const previous = organism.energy diff --git a/packages/simulation/src/systems/MovementSystem.ts b/packages/simulation/src/systems/MovementSystem.ts index 3645d9b..53c693a 100644 --- a/packages/simulation/src/systems/MovementSystem.ts +++ b/packages/simulation/src/systems/MovementSystem.ts @@ -10,7 +10,7 @@ const NO_INTENT: OrganismIntent = { turn: 0, thrust: 0 } /** * Applies each organism's intent: turns it (bounded by `maxTurnRate`), sets its speed (bounded by - * `maxSpeed`, scaled down for juveniles), and moves it, clamped to the world rectangle. Velocity + * the organism's `maxSpeed` gene, scaled down for juveniles), and moves it, clamped to the world rectangle. Velocity * is always derived from heading and speed, never set independently. Also records the turn * actually applied as `lastTurnMagnitude`, which `MetabolismSystem` reads afterward. */ @@ -28,18 +28,19 @@ export class MovementSystem { update(tick: number): void { const dt = this.config.timestep - const { maxTurnRate, maxSpeed, juvenileSpeedScale, maturityAge, senescenceAge } = - this.config.organisms - const maxTurn = maxTurnRate * dt + const { juvenileSpeedScale } = this.config.organisms const width = this.config.environment.width const height = this.config.environment.height for (const organism of this.organisms.values()) { + const genes = organism.genome.physical + const maxTurn = genes.maxTurnRate * dt const intent = this.intentBuffer.get(organism.id) ?? NO_INTENT const turn = clamp(intent.turn, -maxTurn, maxTurn) const heading = wrapAngle(organism.heading + turn) - const stage = lifeStageFor(organism.age, { maturityAge, senescenceAge }) - const scaledMaxSpeed = stage === 'juvenile' ? maxSpeed * juvenileSpeedScale : maxSpeed + const stage = lifeStageFor(organism.age, genes) + const scaledMaxSpeed = + stage === 'juvenile' ? genes.maxSpeed * juvenileSpeedScale : genes.maxSpeed const speed = clamp(intent.thrust, 0, 1) * scaledMaxSpeed const vx = cos(heading) * speed const vy = sin(heading) * speed diff --git a/packages/simulation/src/systems/PerceptionSystem.ts b/packages/simulation/src/systems/PerceptionSystem.ts index 44b1da8..73571b2 100644 --- a/packages/simulation/src/systems/PerceptionSystem.ts +++ b/packages/simulation/src/systems/PerceptionSystem.ts @@ -1,13 +1,14 @@ -import type { WorldConfig } from '@evo/contracts' import type { PerceptionBuffer } from '../buffers/types' import type { OrganismStore } from '../entities/OrganismStore' import type { ResourceState } from '../entities/ResourceStore' import type { SpatialHash } from '../spatial/SpatialHash' -/** Finds the nearest resource within sensor range for every organism. Writes only the buffer. */ +/** + * Finds the nearest resource within each organism's own sensor range (its `sensorRadius` gene). + * Writes only the buffer. + */ export class PerceptionSystem { constructor( - private readonly config: WorldConfig, private readonly organisms: OrganismStore, private readonly resourceIndex: SpatialHash, private readonly perceptionBuffer: PerceptionBuffer @@ -15,9 +16,11 @@ export class PerceptionSystem { update(): void { this.perceptionBuffer.clear() - const sensorRadius = this.config.organisms.sensorRadius for (const organism of this.organisms.values()) { - const hit = this.resourceIndex.nearest({ x: organism.x, y: organism.y }, sensorRadius) + const hit = this.resourceIndex.nearest( + { x: organism.x, y: organism.y }, + organism.genome.sensory.sensorRadius + ) this.perceptionBuffer.set( organism.id, hit diff --git a/packages/simulation/src/systems/ReproductionSystem.ts b/packages/simulation/src/systems/ReproductionSystem.ts index d7ad04e..cddc99b 100644 --- a/packages/simulation/src/systems/ReproductionSystem.ts +++ b/packages/simulation/src/systems/ReproductionSystem.ts @@ -2,8 +2,10 @@ import type { EntityId, WorldConfig } from '@evo/contracts' import type { IdGenerator } from '../entities/IdGenerator' import type { OrganismState, OrganismStore } from '../entities/OrganismStore' import type { EventLog } from '../events/EventLog' +import { copyGenomeAs } from '../genetics/GenomeFactory' import { clamp, TWO_PI, wrapAngle } from '../math/scalar' import { lifeStageFor } from '../organisms/lifeStage' +import { maxEnergyOf, reproductionCostOf, reproductionThresholdOf } from '../organisms/traits' import type { SeededRandom } from '../random/SeededRandom' import type { SpatialHash } from '../spatial/SpatialHash' @@ -12,12 +14,13 @@ import type { SpatialHash } from '../spatial/SpatialHash' * (docs/decisions/0005). Every organism's `reproductionCooldownRemaining` is first decremented by * `deltaTime` (floored at zero); then organisms are processed in ascending id, each considered at * most once as an initiator. An organism attempts only once it clears three silent gates (not - * juvenile, cooldown `<= 0`, `energy >= reproduction.minEnergy`) - organisms that don't clear them - * simply don't attempt, no event. An attempting organism is blocked by, in order, the population - * cap and then the lack of an eligible, not-yet-paired partner within `searchRadius`; both record - * a failed `reproduction-attempted`. On success, each parent pays `offspringInvestment * (1 + overheadFraction)` and - * resets its cooldown; the child starts at age 0 near the parents' midpoint, with `parentIds` in - * ascending order. + * juvenile, cooldown `<= 0`, enough energy to attempt and to pay) - organisms that don't clear + * them simply don't attempt, no event. Every threshold is the organism's own gene + * (docs/decisions/0006). An attempting organism is blocked by, in order, the population cap and + * then the lack of an eligible, not-yet-paired partner within its own `searchRadius`; both record + * a failed `reproduction-attempted`. On success, each parent pays its own cost and resets its own + * cooldown, and a child is born near the parents' midpoint with `parentIds` in ascending order. + * The child's genome is a copy of its mother's (the lower-id parent) under a new `GenomeId`. */ export class ReproductionSystem { constructor( @@ -25,19 +28,14 @@ export class ReproductionSystem { private readonly organisms: OrganismStore, private readonly organismIndex: SpatialHash, private readonly ids: IdGenerator, + private readonly genomeIds: IdGenerator, private readonly random: SeededRandom, private readonly events: EventLog ) {} update(tick: number): void { const dt = this.config.timestep - const { maturityAge, senescenceAge, maxEnergy, radius } = this.config.organisms - const { minEnergy, offspringInvestment, overheadFraction, cooldown, searchRadius } = - this.config.reproduction - const energyCost = offspringInvestment * (1 + overheadFraction) - const offspringEnergy = offspringInvestment + offspringInvestment const { width, height } = this.config.environment - const thresholds = { maturityAge, senescenceAge } const initiators = this.organisms.values() for (const organism of initiators) { @@ -49,9 +47,10 @@ export class ReproductionSystem { const pairedIds = new Set() for (const organism of initiators) { - if (lifeStageFor(organism.age, thresholds) === 'juvenile') continue + const genome = organism.genome + if (lifeStageFor(organism.age, genome.physical) === 'juvenile') continue if (organism.reproductionCooldownRemaining > 0) continue - if (organism.energy < minEnergy) continue + if (organism.energy < reproductionThresholdOf(genome, this.config)) continue if (pairedIds.has(organism.id)) continue if (this.organisms.size >= this.config.maxPopulation) { @@ -67,14 +66,17 @@ export class ReproductionSystem { continue } - const hits = this.organismIndex.queryRadius({ x: organism.x, y: organism.y }, searchRadius) + const hits = this.organismIndex.queryRadius( + { x: organism.x, y: organism.y }, + genome.reproduction.searchRadius + ) const partnerHit = hits.find( (hit) => hit.item.id !== organism.id && !pairedIds.has(hit.item.id) && - lifeStageFor(hit.item.age, thresholds) !== 'juvenile' && + lifeStageFor(hit.item.age, hit.item.genome.physical) !== 'juvenile' && hit.item.reproductionCooldownRemaining <= 0 && - hit.item.energy >= minEnergy + hit.item.energy >= reproductionThresholdOf(hit.item.genome, this.config) ) if (!partnerHit) { this.events.record({ @@ -92,16 +94,23 @@ export class ReproductionSystem { const partner = partnerHit.item pairedIds.add(organism.id) pairedIds.add(partner.id) - organism.energy -= energyCost - partner.energy -= energyCost - organism.reproductionCooldownRemaining = cooldown - partner.reproductionCooldownRemaining = cooldown + organism.energy -= reproductionCostOf(genome, this.config) + partner.energy -= reproductionCostOf(partner.genome, this.config) + organism.reproductionCooldownRemaining = genome.reproduction.cooldown + partner.reproductionCooldownRemaining = partner.genome.reproduction.cooldown const childId = this.ids.allocate() - const parentIds: EntityId[] = - organism.id < partner.id ? [organism.id, partner.id] : [partner.id, organism.id] + const [mother, father] = organism.id < partner.id ? [organism, partner] : [partner, organism] + const parentIds: EntityId[] = [mother.id, father.id] + const parentGenomeIds = + mother.genome.id < father.genome.id + ? [mother.genome.id, father.genome.id] + : [father.genome.id, mother.genome.id] + const childGenome = copyGenomeAs(mother.genome, this.genomeIds.allocate(), parentGenomeIds) + const midX = (organism.x + partner.x) / 2 const midY = (organism.y + partner.y) / 2 + const radius = genome.physical.radius const x = clamp(midX + (this.random.float() * 2 - 1) * radius, 0, width) const y = clamp(midY + (this.random.float() * 2 - 1) * radius, 0, height) const heading = wrapAngle(this.random.float() * TWO_PI) @@ -109,13 +118,17 @@ export class ReproductionSystem { const child: OrganismState = { id: childId, parentIds, + genome: childGenome, x, y, vx: 0, vy: 0, heading, age: 0, - energy: Math.min(offspringEnergy, maxEnergy), + energy: Math.min( + genome.reproduction.offspringInvestment + partner.genome.reproduction.offspringInvestment, + maxEnergyOf(childGenome, this.config) + ), birthTick: tick, reproductionCooldownRemaining: 0, lastTurnMagnitude: 0 @@ -126,6 +139,7 @@ export class ReproductionSystem { type: 'organism-born', tick, organismId: childId, + genomeId: childGenome.id, parentIds, position: { x, y } }) diff --git a/packages/simulation/src/world/World.ts b/packages/simulation/src/world/World.ts index 7493d18..439129e 100644 --- a/packages/simulation/src/world/World.ts +++ b/packages/simulation/src/world/World.ts @@ -1,5 +1,7 @@ import { resolveWorldConfig, validateSeed } from '@evo/config' import type { + EntityId, + Genome, HistoricalOrganismRecord, ValidationResult, WorldConfig, @@ -16,6 +18,9 @@ import { lifeStageFor } from '../organisms/lifeStage' import type { ResourceState } from '../entities/ResourceStore' import { ResourceStore } from '../entities/ResourceStore' import { EventLog } from '../events/EventLog' +import { buildGeneSpecs } from '../genetics/GeneSpecs' +import type { GeneSpec } from '../genetics/GeneSpecs' +import { createFounderGenome, genomeToVector } from '../genetics/GenomeFactory' import { HistoryStore } from '../history/HistoryStore' import { TWO_PI, wrapAngle } from '../math/scalar' import { MetricsCollector } from '../metrics/MetricsCollector' @@ -60,6 +65,8 @@ export class World { private readonly resourceIndex: SpatialHash private readonly organismIndex: SpatialHash private readonly historyStore: HistoryStore + private readonly geneSpecs: GeneSpec[] + private readonly genomeIds: IdGenerator private readonly perceptionBuffer: PerceptionBuffer = new Map() private readonly intentBuffer: IntentBuffer = new Map() @@ -90,17 +97,21 @@ export class World { this.clockImpl = new SimulationClock(this.config.timestep) this.ids = new IdGenerator() + this.genomeIds = new IdGenerator() + this.geneSpecs = buildGeneSpecs(this.config) this.randomStreams = new RandomStreams(this.seed) this.organisms = new OrganismStore() this.resources = new ResourceStore() this.events = new EventLog(this.config.history.maxEvents) this.metricsCollector = new MetricsCollector() + // Grid cell sizes are the upper bound of the gene ranges: a query is correct for any cell + // size, so this only affects efficiency, and it must not depend on an individual genome. this.resourceIndex = new SpatialHash( - this.config.organisms.sensorRadius, + geneRangeMax(this.geneSpecs, 'sensory.sensorRadius'), (resource) => resource ) this.organismIndex = new SpatialHash( - this.config.reproduction.searchRadius, + geneRangeMax(this.geneSpecs, 'reproduction.searchRadius'), (organism) => organism ) this.historyStore = new HistoryStore(this.config.history.maxHistoricalOrganisms) @@ -113,7 +124,6 @@ export class World { this.events ) this.perceptionSystem = new PerceptionSystem( - this.config, this.organisms, this.resourceIndex, this.perceptionBuffer @@ -144,10 +154,11 @@ export class World { this.organisms, this.organismIndex, this.ids, + this.genomeIds, this.randomStreams.get('reproduction'), this.events ) - this.deathSystem = new DeathSystem(this.config, this.organisms, this.events, this.historyStore) + this.deathSystem = new DeathSystem(this.organisms, this.events, this.historyStore) this.events.beginTick() this.spawnInitialPopulation() @@ -164,14 +175,22 @@ export class World { this.environmentSystem.spawnInitial(0) const worldRandom = this.randomStreams.get('world') + const geneticsRandom = this.randomStreams.get('genetics') const { width, height } = this.config.environment for (let i = 0; i < this.config.organisms.initialCount; i++) { const id = this.ids.allocate() const x = worldRandom.float() * width const y = worldRandom.float() * height const heading = wrapAngle(worldRandom.float() * TWO_PI) + const genome = createFounderGenome( + this.genomeIds.allocate(), + this.geneSpecs, + this.config.genetics.brain, + geneticsRandom + ) const organism: OrganismState = { id, + genome, x, y, vx: 0, @@ -189,12 +208,21 @@ export class World { type: 'organism-born', tick: 0, organismId: id, + genomeId: genome.id, parentIds: [], position: { x, y } }) } } + /** + * The genome of an active organism, or `undefined` if there is no such organism. Genomes are + * deeply frozen, so the returned object is safe to hold or share. + */ + genomeOf(organismId: EntityId): Genome | undefined { + return this.organisms.get(organismId)?.genome + } + /** The current tick, simulated time, and timestep. Read-only: `step()` is the only mutator. */ get clock(): ClockState { return this.clockImpl.state() @@ -250,7 +278,9 @@ export class World { tick: this.clockImpl.tick, extinctionTick: this.metricsCollector.extinctionTick, metrics: this.metricsCollector.snapshot(), - nextEntityId: this.ids.nextId + nextEntityId: this.ids.nextId, + nextGenomeId: this.genomeIds.nextId, + geneSpecs: this.geneSpecs }) } @@ -267,11 +297,12 @@ export class World { }, organisms: this.organisms.values().map((organism) => ({ id: organism.id, + genomeId: organism.genome.id, parentIds: organism.parentIds, position: { x: organism.x, y: organism.y }, velocity: { x: organism.vx, y: organism.vy }, heading: organism.heading, - radius: this.config.organisms.radius, + radius: organism.genome.physical.radius, age: organism.age, energy: organism.energy, lifeStage: lifeStageFor(organism.age, this.config.organisms), @@ -311,6 +342,7 @@ export class World { .writeFloat64(clockState.time) .writeFloat64(clockState.deltaTime) hasher.writeUint32(this.ids.nextId) + hasher.writeUint32(this.genomeIds.nextId) for (const name of STREAM_NAMES) { const state = this.randomStreams.get(name).getState() @@ -340,6 +372,20 @@ export class World { .writeFloat64(organism.reproductionCooldownRemaining) hasher.writeUint32(organism.parentIds.length) for (const parentId of organism.parentIds) hasher.writeUint32(parentId) + + const genome = organism.genome + hasher.writeUint32(genome.id) + hasher.writeUint32(genome.parentIds.length) + for (const parentId of genome.parentIds) hasher.writeUint32(parentId) + for (const value of genomeToVector(genome)) hasher.writeFloat64(value) + const { architecture, weights } = genome.brain + hasher + .writeUint32(architecture.inputSize) + .writeUint32(architecture.hiddenSize) + .writeUint32(architecture.memorySize) + .writeUint32(architecture.outputSize) + .writeUint32(weights.length) + for (const weight of weights) hasher.writeFloat64(weight) } const resources = this.resources.values() @@ -372,3 +418,10 @@ export class World { return hasher.digest() } } + +/** The upper bound of a gene's range, used to size spatial grids. */ +function geneRangeMax(specs: readonly GeneSpec[], path: string): number { + const spec = specs.find((candidate) => candidate.path === path) + if (!spec) throw new Error(`missing gene spec ${path}`) + return spec.max +} diff --git a/packages/simulation/src/world/invariants.ts b/packages/simulation/src/world/invariants.ts index 4d2c442..4eff77c 100644 --- a/packages/simulation/src/world/invariants.ts +++ b/packages/simulation/src/world/invariants.ts @@ -1,6 +1,9 @@ import type { MetricsSnapshot, ValidationResult, WorldConfig } from '@evo/contracts' import type { OrganismState } from '../entities/OrganismStore' import type { ResourceState } from '../entities/ResourceStore' +import type { GeneSpec } from '../genetics/GeneSpecs' +import { architectureOf } from '../genetics/GenomeFactory' +import { validateGenome } from '../genetics/GenomeValidation' export interface InvariantContext { readonly organisms: readonly OrganismState[] @@ -11,6 +14,10 @@ export interface InvariantContext { readonly metrics: MetricsSnapshot /** The next id `IdGenerator` will allocate; used to validate `parentIds` reference real ids. */ readonly nextEntityId: number + /** The next genome id that will be allocated; every living genome id is below it. */ + readonly nextGenomeId: number + /** The world's gene table, which every living genome must satisfy. */ + readonly geneSpecs: readonly GeneSpec[] } function checkFinite(label: string, field: string, value: number, errors: string[]): void { @@ -82,7 +89,18 @@ function checkResource( */ export function checkInvariants(context: InvariantContext): ValidationResult { const errors: string[] = [] - const { organisms, resources, config, tick, extinctionTick, metrics, nextEntityId } = context + const { + organisms, + resources, + config, + tick, + extinctionTick, + metrics, + nextEntityId, + nextGenomeId, + geneSpecs + } = context + const seenGenomeIds = new Set() const width = config.environment.width const height = config.environment.height @@ -97,6 +115,20 @@ export function checkInvariants(context: InvariantContext): ValidationResult { } previousOrganismId = organism.id checkOrganism(organism, width, height, tick, nextEntityId, errors) + + const genomeResult = validateGenome(organism.genome, geneSpecs, { + brain: architectureOf(config.genetics.brain), + nextGenomeId + }) + for (const message of genomeResult.errors) { + errors.push(`organism ${organism.id}: genome: ${message}`) + } + if (seenGenomeIds.has(organism.genome.id)) { + errors.push( + `organism ${organism.id}: genome id ${organism.genome.id} is shared with another organism` + ) + } + seenGenomeIds.add(organism.genome.id) } let previousResourceId = -1 diff --git a/packages/simulation/tests/entities.test.ts b/packages/simulation/tests/entities.test.ts index 3926669..4a9325c 100644 --- a/packages/simulation/tests/entities.test.ts +++ b/packages/simulation/tests/entities.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' +import { DEFAULT_WORLD_CONFIG } from '@evo/config' import { IdGenerator } from '../src/entities/IdGenerator' import { OrganismStore } from '../src/entities/OrganismStore' import type { OrganismState } from '../src/entities/OrganismStore' +import { organismFor } from './support/organism' import { ResourceStore } from '../src/entities/ResourceStore' import type { ResourceState } from '../src/entities/ResourceStore' @@ -26,29 +28,17 @@ describe('IdGenerator', () => { }) }) -const organism = (id: number, overrides: Partial = {}): OrganismState => ({ - id, - x: 0, - y: 0, - vx: 0, - vy: 0, - heading: 0, - age: 0, - energy: 50, - parentIds: [], - birthTick: 0, - reproductionCooldownRemaining: 0, - lastTurnMagnitude: 0, - ...overrides -}) +const organism = (id: number, overrides: Partial = {}): OrganismState => + organismFor(DEFAULT_WORLD_CONFIG, id, overrides) describe('OrganismState shape', () => { - it('has no generation counter, genome, or health/alive field (roadmap: no generations)', () => { + it('has no generation counter or health/alive field, and keeps its identity in the genome (roadmap: no generations)', () => { // Reproduction is local and continuous, not generational; a `generation` field would imply - // discrete cohorts that don't exist here, the same way `genomeId`/`health`/`alive` would - // imply mechanics no system implements yet (docs/decisions/0005). + // discrete cohorts that don't exist here, the same way `health`/`alive` would + // imply mechanics no system implements yet (docs/decisions/0005). A separate `genomeId` would + // duplicate the identity the genome already carries (docs/decisions/0006). const keys = Object.keys(organism(1)) - for (const forbidden of ['generation', 'genomeId', 'genome', 'health', 'alive']) { + for (const forbidden of ['generation', 'genomeId', 'health', 'alive']) { expect(keys).not.toContain(forbidden) } }) diff --git a/packages/simulation/tests/genes-in-systems.test.ts b/packages/simulation/tests/genes-in-systems.test.ts new file mode 100644 index 0000000..22f9f56 --- /dev/null +++ b/packages/simulation/tests/genes-in-systems.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest' +import { resolveWorldConfig } from '@evo/config' +import type { GenePath } from '@evo/contracts' +import type { IntentBuffer, PerceptionBuffer } from '../src/buffers/types' +import { IdGenerator } from '../src/entities/IdGenerator' +import { OrganismStore } from '../src/entities/OrganismStore' +import type { OrganismState } from '../src/entities/OrganismStore' +import { ResourceStore } from '../src/entities/ResourceStore' +import type { ResourceState } from '../src/entities/ResourceStore' +import { EventLog } from '../src/events/EventLog' +import { genomeToVector } from '../src/genetics/GenomeFactory' +import { HistoryStore } from '../src/history/HistoryStore' +import { SeededRandom } from '../src/random/SeededRandom' +import { deriveStreamWords } from '../src/random/streams' +import { SpatialHash } from '../src/spatial/SpatialHash' +import { DeathSystem } from '../src/systems/DeathSystem' +import { DecisionSystem } from '../src/systems/DecisionSystem' +import { InteractionSystem } from '../src/systems/InteractionSystem' +import { MetabolismSystem } from '../src/systems/MetabolismSystem' +import { MovementSystem } from '../src/systems/MovementSystem' +import { PerceptionSystem } from '../src/systems/PerceptionSystem' +import { ReproductionSystem } from '../src/systems/ReproductionSystem' +import { organismFor, withGenes } from './support/organism' + +/** + * Each system reads an organism's traits from its own genome, not from global config + * (docs/decisions/0006): two organisms in the same world, under the same config, behave + * differently when their genes differ. + */ +const config = resolveWorldConfig() +const MATURE = 200 + +const mature = (id: number, overrides: Partial = {}): OrganismState => + organismFor(config, id, { age: MATURE, ...overrides }) + +const stream = (name: Parameters[1]) => + new SeededRandom(deriveStreamWords(1, name)) + +const idsFrom = (n: number): IdGenerator => { + const ids = new IdGenerator() + for (let i = 0; i < n; i++) ids.allocate() + return ids +} + +describe('MovementSystem reads physical genes', () => { + function runMovement(organism: OrganismState, intent: { turn: number; thrust: number }) { + const organisms = new OrganismStore() + organisms.add(organism) + const intents: IntentBuffer = new Map([[organism.id, intent]]) + const events = new EventLog(10) + events.beginTick() + new MovementSystem(config, organisms, intents, events).update(1) + return organisms.get(organism.id) as OrganismState + } + + it('uses the organism’s own maxSpeed gene', () => { + const slow = runMovement(withGenes(mature(1), { 'physical.maxSpeed': 10 }), { + turn: 0, + thrust: 1 + }) + const fast = runMovement(withGenes(mature(2), { 'physical.maxSpeed': 30 }), { + turn: 0, + thrust: 1 + }) + expect(slow.vx).toBeCloseTo(10, 10) + expect(fast.vx).toBeCloseTo(30, 10) + }) + + it('clamps the turn to the organism’s own maxTurnRate gene', () => { + const sluggish = runMovement(withGenes(mature(1), { 'physical.maxTurnRate': 1 }), { + turn: 10, + thrust: 0 + }) + const agile = runMovement(withGenes(mature(2), { 'physical.maxTurnRate': 5 }), { + turn: 10, + thrust: 0 + }) + expect(sluggish.heading).toBeCloseTo(1 * config.timestep, 12) + expect(agile.heading).toBeCloseTo(5 * config.timestep, 12) + }) + + it('judges juvenility by the organism’s own maturityAge gene', () => { + const early = withGenes(organismFor(config, 1, { age: 60 }), { 'physical.maturityAge': 50 }) + const late = withGenes(organismFor(config, 2, { age: 60 }), { 'physical.maturityAge': 100 }) + const fullSpeed = runMovement(early, { turn: 0, thrust: 1 }).vx + const juvenileSpeed = runMovement(late, { turn: 0, thrust: 1 }).vx + expect(juvenileSpeed).toBeCloseTo(fullSpeed * config.organisms.juvenileSpeedScale, 10) + }) +}) + +describe('PerceptionSystem reads the sensorRadius gene', () => { + it('lets a long-sighted organism see a resource a short-sighted one cannot', () => { + const organisms = new OrganismStore() + organisms.add(withGenes(mature(1), { 'sensory.sensorRadius': 30 })) + organisms.add(withGenes(mature(2), { 'sensory.sensorRadius': 100 })) + const resource: ResourceState = { id: 3, x: 50, y: 0, remaining: 30 } + const index = new SpatialHash(120, (r) => r) + index.rebuild([resource]) + const buffer: PerceptionBuffer = new Map() + new PerceptionSystem(organisms, index, buffer).update() + expect(buffer.get(1)).toBeNull() + expect(buffer.get(2)).toMatchObject({ resourceId: 3 }) + }) +}) + +describe('DecisionSystem reads the wanderJitter gene', () => { + it('wanders only as far as the organism’s own jitter allows', () => { + const organisms = new OrganismStore() + organisms.add(withGenes(mature(1), { 'behavioral.wanderJitter': 0 })) + organisms.add(withGenes(mature(2), { 'behavioral.wanderJitter': 3 })) + const perception: PerceptionBuffer = new Map() + const intents: IntentBuffer = new Map() + const system = new DecisionSystem(config, organisms, perception, intents, stream('world')) + for (let tick = 0; tick < 50; tick++) { + system.update() + expect(Math.abs(intents.get(1)?.turn ?? 1)).toBe(0) + expect(Math.abs(intents.get(2)?.turn ?? 0)).toBeLessThanOrEqual(3 * config.timestep) + } + }) +}) + +describe('MetabolismSystem reads cost genes and derives size-linked costs', () => { + const costless = resolveWorldConfig({ + organisms: { movementCost: 0, turningCost: 0, sensorCost: 0, juvenileMetabolicScale: 1 } + }) + + function energyAfter(organism: OrganismState, c = costless): number { + const organisms = new OrganismStore() + organisms.add(organism) + const events = new EventLog(10) + events.beginTick() + new MetabolismSystem(c, organisms, events).update(1) + return (organisms.get(organism.id) as OrganismState).energy + } + + it('charges the organism’s own basalCost gene', () => { + const cheap = withGenes(organismFor(costless, 1, { age: MATURE, energy: 100 }), { + 'metabolic.basalCost': 0.3 + }) + expect(energyAfter(cheap)).toBeCloseTo(100 - 0.3 * costless.timestep, 12) + }) + + it('charges a larger body more, in proportion to its size factor', () => { + const small = organismFor(costless, 1, { age: MATURE, energy: 100 }) + const big = withGenes(organismFor(costless, 2, { age: MATURE, energy: 100 }), { + 'physical.radius': 10 + }) + const smallCost = 100 - energyAfter(small) + const bigCost = 100 - energyAfter(big) + expect(bigCost / smallCost).toBeCloseTo(4, 8) + }) + + it('charges more to run a longer sensor', () => { + const sensing = resolveWorldConfig({ + organisms: { basalCost: 0, movementCost: 0, turningCost: 0, juvenileMetabolicScale: 1 } + }) + const near = withGenes(organismFor(sensing, 1, { age: MATURE, energy: 100 }), { + 'sensory.sensorRadius': 30 + }) + const far = withGenes(organismFor(sensing, 2, { age: MATURE, energy: 100 }), { + 'sensory.sensorRadius': 120 + }) + expect((100 - energyAfter(far, sensing)) / (100 - energyAfter(near, sensing))).toBeCloseTo(4, 8) + }) + + it('charges the movementCost and turningCost genes against realized speed and turn', () => { + const moving = resolveWorldConfig({ + organisms: { basalCost: 0, sensorCost: 0, juvenileMetabolicScale: 1 } + }) + const base = organismFor(moving, 1, { age: MATURE, energy: 100, vx: 10, vy: 0 }) + const costly = withGenes(base, { 'metabolic.movementCost': 0.008 }) + const thrifty = withGenes(base, { 'metabolic.movementCost': 0.004 }) + expect((100 - energyAfter(costly, moving)) / (100 - energyAfter(thrifty, moving))).toBeCloseTo( + 2, + 8 + ) + const turning = organismFor(moving, 2, { + age: MATURE, + energy: 100, + lastTurnMagnitude: 0.2 + }) + const sharp = withGenes(turning, { 'metabolic.turningCost': 0.08 }) + const gentle = withGenes(turning, { 'metabolic.turningCost': 0.04 }) + expect((100 - energyAfter(sharp, moving)) / (100 - energyAfter(gentle, moving))).toBeCloseTo( + 2, + 8 + ) + }) +}) + +describe('InteractionSystem reads the radius gene and derives capacity from it', () => { + function feed(organism: OrganismState, resourceX: number) { + const organisms = new OrganismStore() + organisms.add(organism) + const resources = new ResourceStore() + resources.add({ id: 9, x: resourceX, y: 0, remaining: 30 }) + const index = new SpatialHash(60, (r) => r) + index.rebuild(resources.values()) + const events = new EventLog(10) + events.beginTick() + new InteractionSystem(config, organisms, resources, index, events).update(1) + return { organism: organisms.get(organism.id) as OrganismState, resources } + } + + it('reaches as far as the organism’s own radius plus the resource radius', () => { + const distance = 10.5 // a radius-10 body reaches it (10 + 3), a radius-5 body (5 + 3) does not + const big = feed(withGenes(mature(1, { energy: 10 }), { 'physical.radius': 10 }), distance) + const small = feed(mature(2, { energy: 10 }), distance) + expect(big.resources.has(9)).toBe(false) + expect(big.organism.energy).toBe(40) + expect(small.resources.has(9)).toBe(true) + expect(small.organism.energy).toBe(10) + }) + + it('caps energy at the capacity its body size derives', () => { + const tiny = feed(withGenes(mature(1, { energy: 20 }), { 'physical.radius': 2.5 }), 0) + // capacity = 100 * 0.25 = 25, so only 5 of the 30 is absorbed + expect(tiny.organism.energy).toBe(25) + const giant = feed(withGenes(mature(2, { energy: 20 }), { 'physical.radius': 10 }), 0) + expect(giant.organism.energy).toBe(50) + }) +}) + +describe('DeathSystem reads the maxAge gene', () => { + it('kills an organism at its own maximum age, and not before', () => { + const organisms = new OrganismStore() + organisms.add(withGenes(mature(1, { age: 580 }), { 'physical.maxAge': 570 })) + organisms.add(withGenes(mature(2, { age: 580 }), { 'physical.maxAge': 1000 })) + const events = new EventLog(10) + events.beginTick() + new DeathSystem(organisms, events, new HistoryStore(10)).update(5) + expect(organisms.has(1)).toBe(false) + expect(organisms.has(2)).toBe(true) + expect(events.eventsThisTick()[0]).toMatchObject({ organismId: 1, cause: 'age' }) + }) +}) + +describe('ReproductionSystem reads reproduction genes', () => { + function run(organisms: OrganismStore, genomeStart = 100) { + const events = new EventLog(20) + events.beginTick() + const index = new SpatialHash(100, (o) => o) + index.rebuild(organisms.values()) + new ReproductionSystem( + config, + organisms, + index, + idsFrom(50), + idsFrom(genomeStart), + stream('reproduction'), + events + ).update(1) + return events + } + + type Genes = Partial> + + function pair(a: Genes, b: Genes, energy = 100): OrganismStore { + const organisms = new OrganismStore() + organisms.add(withGenes(mature(1, { x: 0, y: 0, energy }), a)) + organisms.add(withGenes(mature(2, { x: 5, y: 0, energy }), b)) + return organisms + } + + it('does not attempt until energy reaches the organism’s own minEnergy gene', () => { + const organisms = pair({ 'reproduction.minEnergy': 90 }, { 'reproduction.minEnergy': 90 }, 80) + expect(run(organisms).eventsThisTick()).toEqual([]) + }) + + it('never attempts when it cannot afford to pay, whatever its minEnergy gene says', () => { + const stingy = { + 'reproduction.minEnergy': 10, + 'reproduction.offspringInvestment': 25 + } as const + // cost is 40, so 30 energy is not enough even though minEnergy is only 10 + expect(run(pair(stingy, stingy, 30)).eventsThisTick()).toEqual([]) + // with 45 energy both attempt, and paying leaves them non-negative + const organisms = pair(stingy, stingy, 45) + run(organisms) + expect(organisms.get(1)?.energy).toBe(5) + expect(organisms.get(2)?.energy).toBe(5) + }) + + it('makes each parent pay its own cost and gives the child the sum of the investments', () => { + const organisms = pair( + { 'reproduction.offspringInvestment': 6 }, + { 'reproduction.offspringInvestment': 20 } + ) + run(organisms) + expect(organisms.get(1)?.energy).toBeCloseTo(100 - 6 * 1.6, 12) + expect(organisms.get(2)?.energy).toBeCloseTo(100 - 20 * 1.6, 12) + expect(organisms.get(50)?.energy).toBe(26) + }) + + it('resets each parent’s cooldown to its own cooldown gene', () => { + const organisms = pair({ 'reproduction.cooldown': 15 }, { 'reproduction.cooldown': 60 }) + run(organisms) + expect(organisms.get(1)?.reproductionCooldownRemaining).toBe(15) + expect(organisms.get(2)?.reproductionCooldownRemaining).toBe(60) + }) + + it('searches within the initiator’s own searchRadius gene', () => { + const near = pair({ 'reproduction.searchRadius': 2 }, { 'reproduction.searchRadius': 2 }) + const events = run(near) + expect(events.eventsThisTick().some((e) => e.type === 'organism-born')).toBe(false) + const far = pair({ 'reproduction.searchRadius': 30 }, { 'reproduction.searchRadius': 30 }) + expect( + run(far) + .eventsThisTick() + .some((e) => e.type === 'organism-born') + ).toBe(true) + }) + + it('gives the child a fresh genome that copies its mother’s, the lower-id parent', () => { + const organisms = pair({ 'physical.maxSpeed': 11 }, { 'physical.maxSpeed': 33 }) + const events = run(organisms, 100) + const child = organisms.get(50) as OrganismState + const mother = organisms.get(1) as OrganismState + expect(child.genome.id).toBe(100) + expect(child.genome.parentIds).toEqual([1, 2]) + expect(child.genome).not.toBe(mother.genome) + expect(child.genome.physical.maxSpeed).toBe(11) + expect(genomeToVector(child.genome)).toEqual(genomeToVector(mother.genome)) + expect(child.genome.brain.weights).toEqual(mother.genome.brain.weights) + expect(events.eventsThisTick().find((e) => e.type === 'organism-born')).toMatchObject({ + organismId: 50, + genomeId: 100, + parentIds: [1, 2] + }) + }) + + it('records parent genome ids in ascending order even when ids are out of step', () => { + const organisms = new OrganismStore() + const a = mature(1, { x: 0, y: 0, energy: 100 }) + const b = mature(2, { x: 5, y: 0, energy: 100 }) + organisms.add({ ...a, genome: { ...a.genome, id: 9 } }) + organisms.add({ ...b, genome: { ...b.genome, id: 4 } }) + run(organisms) + expect(organisms.get(50)?.genome.parentIds).toEqual([4, 9]) + }) +}) diff --git a/packages/simulation/tests/golden/state-hashes.json b/packages/simulation/tests/golden/state-hashes.json index 423fa74..17a39b5 100644 --- a/packages/simulation/tests/golden/state-hashes.json +++ b/packages/simulation/tests/golden/state-hashes.json @@ -1,13 +1,13 @@ { - "hashSchemaVersion": 2, - "engineVersion": "0.4.0", + "hashSchemaVersion": 3, + "engineVersion": "0.5.0", "entries": [ { "name": "default-1k", "seed": 12345, "config": {}, "steps": 1000, - "expectedHash": "aac2e4db4b326dac83633a859e5bb48d" + "expectedHash": "0e0c5b6ae8ae22a576c3394f9e854359" }, { "name": "scenario-a-foraging-5k", @@ -16,7 +16,7 @@ "organisms": { "initialCount": 10 } }, "steps": 5000, - "expectedHash": "21b4f619300f36d1bd2cecb52e857767" + "expectedHash": "ec69e068465071d5d412b12cf564a862" }, { "name": "scenario-b-extinction-20k", @@ -26,7 +26,7 @@ "resources": { "initialCount": 0, "spawnRate": 0 } }, "steps": 20000, - "expectedHash": "1b78edbe8d55c057b849d8770041f8db" + "expectedHash": "8a7fbd77fbd9e14fc5492774f7793258" }, { "name": "verbose-events-200", @@ -35,7 +35,7 @@ "history": { "eventDetail": "verbose", "maxEvents": 100000 } }, "steps": 200, - "expectedHash": "de4a95eeba83778374fdd4673088f9dc" + "expectedHash": "e8a18cfc4bdf5df438f559c4d942b7fb" }, { "name": "zero-population-100", @@ -45,14 +45,14 @@ "resources": { "initialCount": 0 } }, "steps": 100, - "expectedHash": "0f54e5597014e1fa0b395ac252b66589" + "expectedHash": "3cc2d631d6afa976f76cda2f5717fb35" }, { "name": "long-run-100k", "seed": 999, "config": {}, "steps": 100000, - "expectedHash": "e8e4a47dc3de9a1d4179c34771cbe2c1" + "expectedHash": "ac2184973fc3d2d1910ead4ad885367c" } ] } diff --git a/packages/simulation/tests/hash-clock.test.ts b/packages/simulation/tests/hash-clock.test.ts index 2b75a61..bde1d20 100644 --- a/packages/simulation/tests/hash-clock.test.ts +++ b/packages/simulation/tests/hash-clock.test.ts @@ -5,13 +5,13 @@ import { HASH_SCHEMA_VERSION, SimulationClock, StateHasher, utf8Encode } from '. describe('StateHasher', () => { it('matches the published vectors', () => { expect(new StateHasher().digest()).toBe('027ae52ecfc796215593990d4b41437c') - expect(StateHasher.withHeader().digest()).toBe('dfb410a7fb90a35283f01a1c42a10d0b') + expect(StateHasher.withHeader().digest()).toBe('6df37fdcc239ba2c992d1d705f53c393') const sample = StateHasher.withHeader() .writeUint32(7) .writeFloat64(-0) .writeFloat64(0.5) .writeUint8(1) - expect(sample.digest()).toBe('7bc1354f42366f6b62c02fb98d5e2292') + expect(sample.digest()).toBe('486214610a2227258a0e30943b304938') }) it('canonicalizes -0 to +0', () => { @@ -85,7 +85,7 @@ describe('StateHasher', () => { }) it('exposes the schema version written into the header', () => { - expect(HASH_SCHEMA_VERSION).toBe(2) + expect(HASH_SCHEMA_VERSION).toBe(3) }) it('writes booleans as one byte', () => { diff --git a/packages/simulation/tests/invariants.test.ts b/packages/simulation/tests/invariants.test.ts index 70ef852..f7b1535 100644 --- a/packages/simulation/tests/invariants.test.ts +++ b/packages/simulation/tests/invariants.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_WORLD_CONFIG } from '@evo/config' import type { MetricsSnapshot } from '@evo/contracts' +import { buildGeneSpecs } from '../src/genetics/GeneSpecs' import { checkInvariants } from '../src/world/invariants' import type { InvariantContext } from '../src/world/invariants' import type { OrganismState } from '../src/entities/OrganismStore' +import { organismFor, withGenes } from './support/organism' import type { ResourceState } from '../src/entities/ResourceStore' const zeroMetrics: MetricsSnapshot = { @@ -19,21 +21,8 @@ const zeroMetrics: MetricsSnapshot = { energyWasted: 0 } -const organism = (id: number, overrides: Partial = {}): OrganismState => ({ - id, - x: 10, - y: 10, - vx: 0, - vy: 0, - heading: 0, - age: 1, - energy: 50, - parentIds: [], - birthTick: 0, - reproductionCooldownRemaining: 0, - lastTurnMagnitude: 0, - ...overrides -}) +const organism = (id: number, overrides: Partial = {}): OrganismState => + organismFor(DEFAULT_WORLD_CONFIG, id, overrides) const resource = (id: number, overrides: Partial = {}): ResourceState => ({ id, @@ -51,6 +40,8 @@ const baseContext = (overrides: Partial = {}): InvariantContex extinctionTick: null, metrics: zeroMetrics, nextEntityId: 1000, + nextGenomeId: 1000, + geneSpecs: buildGeneSpecs(DEFAULT_WORLD_CONFIG), ...overrides }) @@ -240,3 +231,41 @@ describe('checkInvariants', () => { expect(result.errors.length).toBeGreaterThanOrEqual(3) }) }) + +describe('genome invariants', () => { + it('accepts organisms whose genomes are valid', () => { + expect(checkInvariants(baseContext({ organisms: [organism(1), organism(2)] })).ok).toBe(true) + }) + + it('reports a gene outside its range, naming the organism and the gene', () => { + const broken = withGenes(organism(1), { 'physical.maxSpeed': 1000 }) + const result = checkInvariants(baseContext({ organisms: [broken] })) + expect(result.ok).toBe(false) + expect(result.errors).toContain( + 'organism 1: genome: physical.maxSpeed (1000) is outside its range [10, 40]' + ) + }) + + it('reports life-stage ages that are out of order', () => { + const broken = withGenes(organism(1), { + 'physical.maturityAge': 150, + 'physical.senescenceAge': 150 + }) + expect(checkInvariants(baseContext({ organisms: [broken] })).errors).toContain( + 'organism 1: genome: physical.senescenceAge must be greater than physical.maturityAge' + ) + }) + + it('reports a genome id that has not been allocated', () => { + const result = checkInvariants(baseContext({ nextGenomeId: 1, organisms: [organism(5)] })) + expect(result.errors).toContain('organism 5: genome: genome id (5) has not been allocated yet') + }) + + it('reports two organisms sharing one genome id', () => { + const first = organism(1) + const second = { ...organism(2), genome: first.genome } + expect(checkInvariants(baseContext({ organisms: [first, second] })).errors).toContain( + 'organism 2: genome id 1 is shared with another organism' + ) + }) +}) diff --git a/packages/simulation/tests/metrics.test.ts b/packages/simulation/tests/metrics.test.ts index 125a378..8aa6aa9 100644 --- a/packages/simulation/tests/metrics.test.ts +++ b/packages/simulation/tests/metrics.test.ts @@ -25,8 +25,8 @@ describe('MetricsCollector', () => { it('tallies every countable event type', () => { const metrics = new MetricsCollector() const events: WorldEvent[] = [ - { type: 'organism-born', tick: 0, organismId: 1, parentIds: [], position: pos }, - { type: 'organism-born', tick: 0, organismId: 2, parentIds: [], position: pos }, + { type: 'organism-born', tick: 0, organismId: 1, genomeId: 1, parentIds: [], position: pos }, + { type: 'organism-born', tick: 0, organismId: 2, genomeId: 2, parentIds: [], position: pos }, { type: 'organism-died', tick: 1, organismId: 1, cause: 'starvation', position: pos }, { type: 'organism-died', tick: 1, organismId: 2, cause: 'age', position: pos }, { type: 'resource-spawned', tick: 0, resourceId: 1, position: pos }, @@ -77,10 +77,10 @@ describe('MetricsCollector', () => { it('accumulates across multiple calls', () => { const metrics = new MetricsCollector() metrics.recordEvents([ - { type: 'organism-born', tick: 0, organismId: 1, parentIds: [], position: pos } + { type: 'organism-born', tick: 0, organismId: 1, genomeId: 1, parentIds: [], position: pos } ]) metrics.recordEvents([ - { type: 'organism-born', tick: 1, organismId: 2, parentIds: [], position: pos } + { type: 'organism-born', tick: 1, organismId: 2, genomeId: 2, parentIds: [], position: pos } ]) expect(metrics.snapshot().organismsBorn).toBe(2) }) diff --git a/packages/simulation/tests/stream-usage.test.ts b/packages/simulation/tests/stream-usage.test.ts index 3a9659f..d116e43 100644 --- a/packages/simulation/tests/stream-usage.test.ts +++ b/packages/simulation/tests/stream-usage.test.ts @@ -9,7 +9,8 @@ import { fileURLToPath } from 'node:url' * a system that draws from an unused stream would still be reproducible (same seed -> same * result); it just wouldn't be *isolated* in spirit. Reading a stream that the current phase has * no business touching should fail loudly here, not be discovered by accident later. `reproduction` - * left this list in Phase 3 once `ReproductionSystem` became real (docs/decisions/0005). + * left this list in Phase 3 once `ReproductionSystem` became real (docs/decisions/0005), and + * `genetics` in Phase 4 once founders' genomes were drawn from it (docs/decisions/0006). */ const ROOT = fileURLToPath(new URL('..', import.meta.url)) @@ -32,7 +33,7 @@ const engineFiles = [ ...collectFiles(join(ROOT, 'src', 'world')) ] -const RESERVED_FOR_LATER_PHASES = ['genetics', 'learning', 'events'] as const +const RESERVED_FOR_LATER_PHASES = ['learning', 'events'] as const describe('reserved random streams are never drawn from by systems/ or world/', () => { it.each(RESERVED_FOR_LATER_PHASES)( diff --git a/packages/simulation/tests/support/organism.ts b/packages/simulation/tests/support/organism.ts new file mode 100644 index 0000000..b8b9ccf --- /dev/null +++ b/packages/simulation/tests/support/organism.ts @@ -0,0 +1,64 @@ +import { GENE_DEFINITIONS } from '@evo/contracts' +import type { GenePath, WorldConfig } from '@evo/contracts' +import type { OrganismState } from '../../src/entities/OrganismStore' +import { buildGeneSpecs } from '../../src/genetics/GeneSpecs' +import { + createFounderGenome, + genomeFromParts, + genomeToVector +} from '../../src/genetics/GenomeFactory' +import { SeededRandom } from '../../src/random/SeededRandom' +import { deriveStreamWords } from '../../src/random/streams' + +/** + * An organism whose genome carries `config`'s founder baseline, so a system under test sees the + * same traits the test configured. The genome id equals the organism id and the brain weights + * are drawn from a fixed `genetics` stream, so organisms are reproducible but not identical. + */ +export function organismFor( + config: WorldConfig, + id: number, + overrides: Partial = {} +): OrganismState { + const genome = createFounderGenome( + id, + buildGeneSpecs(config), + config.genetics.brain, + new SeededRandom(deriveStreamWords(id + 1, 'genetics')) + ) + return { + id, + genome, + x: 0, + y: 0, + vx: 0, + vy: 0, + heading: 0, + age: 0, + energy: 50, + parentIds: [], + birthTick: 0, + reproductionCooldownRemaining: 0, + lastTurnMagnitude: 0, + ...overrides + } +} + +/** The same organism with some scalar genes replaced (genome id and brain unchanged). */ +export function withGenes( + organism: OrganismState, + changes: Partial> +): OrganismState { + const vector = genomeToVector(organism.genome).map((value, index) => { + const path = (GENE_DEFINITIONS[index] as { path: GenePath }).path + return changes[path] ?? value + }) + const genome = genomeFromParts({ + id: organism.genome.id, + parentIds: organism.genome.parentIds, + vector, + architecture: organism.genome.brain.architecture, + weights: organism.genome.brain.weights + }) + return { ...organism, genome } +} diff --git a/packages/simulation/tests/systems.test.ts b/packages/simulation/tests/systems.test.ts index b8dc2c6..8cfc0f1 100644 --- a/packages/simulation/tests/systems.test.ts +++ b/packages/simulation/tests/systems.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveWorldConfig } from '@evo/config' import type { PerceptionBuffer, IntentBuffer } from '../src/buffers/types' import { IdGenerator } from '../src/entities/IdGenerator' +import { organismFor as organism } from './support/organism' import { OrganismStore } from '../src/entities/OrganismStore' import type { OrganismState } from '../src/entities/OrganismStore' import { ResourceStore } from '../src/entities/ResourceStore' @@ -24,22 +25,6 @@ import { ReproductionSystem } from '../src/systems/ReproductionSystem' const stream = (seed: number, name: Parameters[1]) => new SeededRandom(deriveStreamWords(seed, name)) -const organism = (id: number, overrides: Partial = {}): OrganismState => ({ - id, - x: 0, - y: 0, - vx: 0, - vy: 0, - heading: 0, - age: 0, - energy: 50, - parentIds: [], - birthTick: 0, - reproductionCooldownRemaining: 0, - lastTurnMagnitude: 0, - ...overrides -}) - const resource = (id: number, overrides: Partial = {}): ResourceState => ({ id, x: 0, @@ -141,8 +126,8 @@ describe('PerceptionSystem', () => { it('finds the nearest resource within sensor range and null otherwise', () => { const config = resolveWorldConfig({ organisms: { sensorRadius: 10 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0 })) - organisms.add(organism(2, { x: 100, y: 100 })) + organisms.add(organism(config, 1, { x: 0, y: 0 })) + organisms.add(organism(config, 2, { x: 100, y: 100 })) const resources = new ResourceStore() resources.add(resource(1, { x: 5, y: 0 })) resources.add(resource(2, { x: 3, y: 0 })) @@ -150,7 +135,7 @@ describe('PerceptionSystem', () => { index.rebuild(resources.values()) const buffer: PerceptionBuffer = new Map() - new PerceptionSystem(config, organisms, index, buffer).update() + new PerceptionSystem(organisms, index, buffer).update() expect(buffer.get(1)?.resourceId).toBe(2) expect(buffer.get(2)).toBeNull() @@ -159,12 +144,12 @@ describe('PerceptionSystem', () => { it('clears stale entries for organisms that no longer exist', () => { const config = resolveWorldConfig({ organisms: { sensorRadius: 10 } }) const organisms = new OrganismStore() - organisms.add(organism(1)) + organisms.add(organism(config, 1)) const index = makeResourceIndex() index.rebuild([]) const buffer: PerceptionBuffer = new Map([[99, null]]) - new PerceptionSystem(config, organisms, index, buffer).update() + new PerceptionSystem(organisms, index, buffer).update() expect(buffer.has(99)).toBe(false) expect(buffer.has(1)).toBe(true) @@ -175,7 +160,7 @@ describe('DecisionSystem', () => { it('seeks directly toward a perceived resource with zero turn error', () => { const config = resolveWorldConfig() const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0, heading: 0 })) + organisms.add(organism(config, 1, { x: 0, y: 0, heading: 0 })) const perceptionBuffer: PerceptionBuffer = new Map([ [1, { resourceId: 1, position: { x: 10, y: 0 }, distanceSquared: 100 }] ]) @@ -197,7 +182,7 @@ describe('DecisionSystem', () => { it('computes the correct signed turn toward an off-axis resource', () => { const config = resolveWorldConfig() const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0, heading: 0 })) + organisms.add(organism(config, 1, { x: 0, y: 0, heading: 0 })) const perceptionBuffer: PerceptionBuffer = new Map([ [1, { resourceId: 1, position: { x: 0, y: 10 }, distanceSquared: 100 }] ]) @@ -217,7 +202,7 @@ describe('DecisionSystem', () => { it('wanders with a bounded, RNG-driven turn and half thrust when nothing is perceived', () => { const config = resolveWorldConfig({ organisms: { wanderJitter: 2 }, timestep: 0.5 }) const organisms = new OrganismStore() - organisms.add(organism(1)) + organisms.add(organism(config, 1)) const perceptionBuffer: PerceptionBuffer = new Map([[1, null]]) const intentBuffer: IntentBuffer = new Map() @@ -238,7 +223,7 @@ describe('DecisionSystem', () => { const config = resolveWorldConfig() const run = () => { const organisms = new OrganismStore() - organisms.add(organism(1)) + organisms.add(organism(config, 1)) const perceptionBuffer: PerceptionBuffer = new Map([[1, null]]) const intentBuffer: IntentBuffer = new Map() new DecisionSystem( @@ -258,7 +243,7 @@ describe('MovementSystem', () => { it('clamps turning to maxTurnRate * timestep', () => { const config = resolveWorldConfig({ organisms: { maxTurnRate: 1, maxSpeed: 0 }, timestep: 0.5 }) const organisms = new OrganismStore() - organisms.add(organism(1, { heading: 0 })) + organisms.add(organism(config, 1, { heading: 0 })) const intentBuffer: IntentBuffer = new Map([[1, { turn: 10, thrust: 0 }]]) const events = new EventLog(10) events.beginTick() @@ -271,7 +256,7 @@ describe('MovementSystem', () => { it('derives velocity from heading and thrust, scaled by maxSpeed', () => { const config = resolveWorldConfig({ organisms: { maxSpeed: 10, maxTurnRate: 100 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { heading: 0, age: 100 })) + organisms.add(organism(config, 1, { heading: 0, age: 100 })) const intentBuffer: IntentBuffer = new Map([[1, { turn: 0, thrust: 0.5 }]]) const events = new EventLog(10) events.beginTick() @@ -288,7 +273,7 @@ describe('MovementSystem', () => { organisms: { maxSpeed: 10, maxTurnRate: 100, maturityAge: 90, juvenileSpeedScale: 0.4 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { heading: 0, age: 1 })) + organisms.add(organism(config, 1, { heading: 0, age: 1 })) const intentBuffer: IntentBuffer = new Map([[1, { turn: 0, thrust: 1 }]]) const events = new EventLog(10) events.beginTick() @@ -301,7 +286,7 @@ describe('MovementSystem', () => { it('records the clamped turn magnitude as lastTurnMagnitude', () => { const config = resolveWorldConfig({ organisms: { maxTurnRate: 1 }, timestep: 0.5 }) const organisms = new OrganismStore() - organisms.add(organism(1, { heading: 0 })) + organisms.add(organism(config, 1, { heading: 0 })) const intentBuffer: IntentBuffer = new Map([[1, { turn: -10, thrust: 0 }]]) const events = new EventLog(10) events.beginTick() @@ -318,7 +303,7 @@ describe('MovementSystem', () => { timestep: 10 }) const organisms = new OrganismStore() - organisms.add(organism(1, { x: 90, y: 5, heading: 0 })) + organisms.add(organism(config, 1, { x: 90, y: 5, heading: 0 })) const intentBuffer: IntentBuffer = new Map([[1, { turn: 0, thrust: 1 }]]) const events = new EventLog(10) events.beginTick() @@ -331,7 +316,7 @@ describe('MovementSystem', () => { it('treats a missing intent as "do nothing" rather than throwing', () => { const config = resolveWorldConfig() const organisms = new OrganismStore() - organisms.add(organism(1, { x: 5, y: 5 })) + organisms.add(organism(config, 1, { x: 5, y: 5 })) const events = new EventLog(10) events.beginTick() expect(() => new MovementSystem(config, organisms, new Map(), events).update(1)).not.toThrow() @@ -345,7 +330,7 @@ describe('MovementSystem', () => { [verbose, 1] ] as const) { const organisms = new OrganismStore() - organisms.add(organism(1)) + organisms.add(organism(config, 1)) const intentBuffer: IntentBuffer = new Map([[1, { turn: 0, thrust: 1 }]]) const events = new EventLog(10) events.beginTick() @@ -362,7 +347,7 @@ describe('InteractionSystem', () => { resources: { radius: 1 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0, energy: 50, age: 100 })) + organisms.add(organism(config, 1, { x: 0, y: 0, energy: 50, age: 100 })) const resources = new ResourceStore() resources.add(resource(1, { x: 1, y: 0, remaining: config.resources.energyValue })) const index = makeResourceIndex() @@ -391,7 +376,7 @@ describe('InteractionSystem', () => { resources: { radius: 1 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { x: 2.5, y: 0, energy: 40, age: 1 })) + organisms.add(organism(config, 1, { x: 2.5, y: 0, energy: 40, age: 1 })) const resources = new ResourceStore() resources.add(resource(1, { x: 0, y: 0, remaining: config.resources.energyValue })) const index = makeResourceIndex() @@ -409,8 +394,8 @@ describe('InteractionSystem', () => { it('never lets two organisms consume the same resource in one tick', () => { const config = resolveWorldConfig({ organisms: { radius: 5 }, resources: { radius: 5 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0, energy: 10 })) - organisms.add(organism(2, { x: 1, y: 0, energy: 10 })) + organisms.add(organism(config, 1, { x: 0, y: 0, energy: 10 })) + organisms.add(organism(config, 2, { x: 1, y: 0, energy: 10 })) const resources = new ResourceStore() resources.add(resource(1, { x: 0.5, y: 0 })) const index = makeResourceIndex() @@ -432,7 +417,7 @@ describe('InteractionSystem', () => { it('leaves an organism unfed when nothing is within its capture radius', () => { const config = resolveWorldConfig({ organisms: { radius: 1 }, resources: { radius: 1 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0, energy: 10 })) + organisms.add(organism(config, 1, { x: 0, y: 0, energy: 10 })) const resources = new ResourceStore() resources.add(resource(1, { x: 1000, y: 0 })) const index = makeResourceIndex() @@ -454,7 +439,7 @@ describe('InteractionSystem', () => { history: { maxEvents: 10, eventDetail: 'verbose' } }) const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0 })) + organisms.add(organism(config, 1, { x: 0, y: 0 })) const resources = new ResourceStore() resources.add(resource(1, { x: 0, y: 0 })) const index = makeResourceIndex() @@ -478,7 +463,7 @@ describe('MetabolismSystem', () => { timestep: 2 }) const organisms = new OrganismStore() - organisms.add(organism(1, { vx: 3, vy: 4, energy: 100, age: 100 })) + organisms.add(organism(config, 1, { vx: 3, vy: 4, energy: 100, age: 100 })) const events = new EventLog(10) events.beginTick() @@ -502,7 +487,7 @@ describe('MetabolismSystem', () => { timestep: 1 }) const organisms = new OrganismStore() - organisms.add(organism(1, { energy: 100, age: 100, lastTurnMagnitude: 2 })) + organisms.add(organism(config, 1, { energy: 100, age: 100, lastTurnMagnitude: 2 })) const events = new EventLog(10) events.beginTick() @@ -525,7 +510,7 @@ describe('MetabolismSystem', () => { timestep: 1 }) const organisms = new OrganismStore() - organisms.add(organism(1, { energy: 100, age: 5 })) + organisms.add(organism(config, 1, { energy: 100, age: 5 })) const events = new EventLog(10) events.beginTick() @@ -537,7 +522,7 @@ describe('MetabolismSystem', () => { it('does not clamp energy at zero (Death owns removal)', () => { const config = resolveWorldConfig({ organisms: { basalCost: 1000 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { energy: 1 })) + organisms.add(organism(config, 1, { energy: 1 })) const events = new EventLog(10) events.beginTick() new MetabolismSystem(config, organisms, events).update(1) @@ -547,7 +532,7 @@ describe('MetabolismSystem', () => { it('emits energy-changed only when history.eventDetail is verbose', () => { const verbose = resolveWorldConfig({ history: { maxEvents: 10, eventDetail: 'verbose' } }) const organisms = new OrganismStore() - organisms.add(organism(1)) + organisms.add(organism(verbose, 1)) const events = new EventLog(10) events.beginTick() new MetabolismSystem(verbose, organisms, events).update(1) @@ -563,7 +548,7 @@ describe('ReproductionSystem', () => { it('records a failed attempt with no-partner when no eligible partner is in range', () => { const config = resolveWorldConfig() const organisms = new OrganismStore() - organisms.add(organism(1, { age: 100, energy: 100 })) + organisms.add(organism(config, 1, { age: 100, energy: 100 })) const index = makeOrganismIndex() index.rebuild(organisms.values()) const events = new EventLog(10) @@ -574,6 +559,7 @@ describe('ReproductionSystem', () => { organisms, index, new IdGenerator(), + idsFrom(100), stream(1, 'reproduction'), events ).update(1) @@ -596,8 +582,8 @@ describe('ReproductionSystem', () => { it('pairs two eligible organisms: both pay the investment plus overhead, both get the cooldown, a child is born', () => { const config = resolveWorldConfig() const organisms = new OrganismStore() - organisms.add(organism(1, { x: 10, y: 10, age: 100, energy: 100 })) - organisms.add(organism(2, { x: 11, y: 10, age: 100, energy: 100 })) + organisms.add(organism(config, 1, { x: 10, y: 10, age: 100, energy: 100 })) + organisms.add(organism(config, 2, { x: 11, y: 10, age: 100, energy: 100 })) const index = makeOrganismIndex() index.rebuild(organisms.values()) const events = new EventLog(10) @@ -608,6 +594,7 @@ describe('ReproductionSystem', () => { organisms, index, idsFrom(3), + idsFrom(100), stream(1, 'reproduction'), events ).update(1) @@ -635,9 +622,9 @@ describe('ReproductionSystem', () => { it('never pairs the same organism twice in one tick', () => { const config = resolveWorldConfig({ reproduction: { searchRadius: 100 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0, age: 100, energy: 100 })) - organisms.add(organism(2, { x: 1, y: 0, age: 100, energy: 100 })) - organisms.add(organism(3, { x: 2, y: 0, age: 100, energy: 100 })) + organisms.add(organism(config, 1, { x: 0, y: 0, age: 100, energy: 100 })) + organisms.add(organism(config, 2, { x: 1, y: 0, age: 100, energy: 100 })) + organisms.add(organism(config, 3, { x: 2, y: 0, age: 100, energy: 100 })) const index = makeOrganismIndex(100) index.rebuild(organisms.values()) const events = new EventLog(10) @@ -648,6 +635,7 @@ describe('ReproductionSystem', () => { organisms, index, idsFrom(4), + idsFrom(100), stream(1, 'reproduction'), events ).update(1) @@ -662,8 +650,8 @@ describe('ReproductionSystem', () => { it('does not let a juvenile initiate or be chosen as a partner', () => { const config = resolveWorldConfig() const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0, age: 1, energy: 100 })) - organisms.add(organism(2, { x: 1, y: 0, age: 100, energy: 100 })) + organisms.add(organism(config, 1, { x: 0, y: 0, age: 1, energy: 100 })) + organisms.add(organism(config, 2, { x: 1, y: 0, age: 100, energy: 100 })) const index = makeOrganismIndex() index.rebuild(organisms.values()) const events = new EventLog(10) @@ -674,6 +662,7 @@ describe('ReproductionSystem', () => { organisms, index, new IdGenerator(), + idsFrom(100), stream(1, 'reproduction'), events ).update(1) @@ -691,7 +680,7 @@ describe('ReproductionSystem', () => { it('gates on a positive cooldown, silently, and decrements cooldowns by deltaTime each tick', () => { const config = resolveWorldConfig({ timestep: 2 }) const organisms = new OrganismStore() - organisms.add(organism(1, { age: 100, energy: 100, reproductionCooldownRemaining: 5 })) + organisms.add(organism(config, 1, { age: 100, energy: 100, reproductionCooldownRemaining: 5 })) const index = makeOrganismIndex() index.rebuild(organisms.values()) const events = new EventLog(10) @@ -702,6 +691,7 @@ describe('ReproductionSystem', () => { organisms, index, new IdGenerator(), + idsFrom(100), stream(1, 'reproduction'), events ).update(1) @@ -713,7 +703,7 @@ describe('ReproductionSystem', () => { it('floors the decremented cooldown at zero rather than going negative', () => { const config = resolveWorldConfig({ timestep: 2 }) const organisms = new OrganismStore() - organisms.add(organism(1, { age: 100, energy: 100, reproductionCooldownRemaining: 1 })) + organisms.add(organism(config, 1, { age: 100, energy: 100, reproductionCooldownRemaining: 1 })) const index = makeOrganismIndex() index.rebuild(organisms.values()) const events = new EventLog(10) @@ -724,6 +714,7 @@ describe('ReproductionSystem', () => { organisms, index, new IdGenerator(), + idsFrom(100), stream(1, 'reproduction'), events ).update(1) @@ -734,8 +725,8 @@ describe('ReproductionSystem', () => { it('rejects an attempt at the population cap without spending energy or a partner search', () => { const config = resolveWorldConfig({ maxPopulation: 2, organisms: { initialCount: 2 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { x: 0, y: 0, age: 100, energy: 100 })) - organisms.add(organism(2, { x: 1, y: 0, age: 100, energy: 100 })) + organisms.add(organism(config, 1, { x: 0, y: 0, age: 100, energy: 100 })) + organisms.add(organism(config, 2, { x: 1, y: 0, age: 100, energy: 100 })) const index = makeOrganismIndex() index.rebuild(organisms.values()) const events = new EventLog(10) @@ -746,6 +737,7 @@ describe('ReproductionSystem', () => { organisms, index, new IdGenerator(), + idsFrom(100), stream(1, 'reproduction'), events ).update(1) @@ -777,7 +769,7 @@ describe('ReproductionSystem', () => { it('gates silently on energy below minEnergy', () => { const config = resolveWorldConfig({ reproduction: { minEnergy: 60 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { age: 100, energy: 59 })) + organisms.add(organism(config, 1, { age: 100, energy: 59 })) const index = makeOrganismIndex() index.rebuild(organisms.values()) const events = new EventLog(10) @@ -788,6 +780,7 @@ describe('ReproductionSystem', () => { organisms, index, new IdGenerator(), + idsFrom(100), stream(1, 'reproduction'), events ).update(1) @@ -800,13 +793,13 @@ describe('DeathSystem', () => { it('removes an organism that starved and records the cause', () => { const config = resolveWorldConfig() const organisms = new OrganismStore() - organisms.add(organism(1, { energy: 0, x: 3, y: 4 })) - organisms.add(organism(2, { energy: 50 })) + organisms.add(organism(config, 1, { energy: 0, x: 3, y: 4 })) + organisms.add(organism(config, 2, { energy: 50 })) const events = new EventLog(10) events.beginTick() const history = new HistoryStore(10) - new DeathSystem(config, organisms, events, history).update(5) + new DeathSystem(organisms, events, history).update(5) expect(organisms.has(1)).toBe(false) expect(organisms.has(2)).toBe(true) @@ -826,12 +819,12 @@ describe('DeathSystem', () => { organisms: { maturityAge: 1, senescenceAge: 5, maxAge: 10 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { age: 10, energy: 50 })) + organisms.add(organism(config, 1, { age: 10, energy: 50 })) const events = new EventLog(10) events.beginTick() const history = new HistoryStore(10) - new DeathSystem(config, organisms, events, history).update(1) + new DeathSystem(organisms, events, history).update(1) expect(organisms.has(1)).toBe(false) expect(events.eventsThisTick()[0]).toMatchObject({ cause: 'age' }) @@ -840,37 +833,44 @@ describe('DeathSystem', () => { it('leaves healthy organisms alone', () => { const config = resolveWorldConfig({ organisms: { maxAge: 1000 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { energy: 1, age: 1 })) + organisms.add(organism(config, 1, { energy: 1, age: 1 })) const events = new EventLog(10) events.beginTick() - new DeathSystem(config, organisms, events, new HistoryStore(10)).update(1) + new DeathSystem(organisms, events, new HistoryStore(10)).update(1) expect(organisms.has(1)).toBe(true) }) it('processes every organism even while removing some (ascending id order)', () => { const config = resolveWorldConfig({ organisms: { maxAge: 1000 } }) const organisms = new OrganismStore() - organisms.add(organism(1, { energy: 0 })) - organisms.add(organism(2, { energy: 50 })) - organisms.add(organism(3, { energy: 0 })) + organisms.add(organism(config, 1, { energy: 0 })) + organisms.add(organism(config, 2, { energy: 50 })) + organisms.add(organism(config, 3, { energy: 0 })) const events = new EventLog(10) events.beginTick() - new DeathSystem(config, organisms, events, new HistoryStore(10)).update(1) + new DeathSystem(organisms, events, new HistoryStore(10)).update(1) expect(organisms.values().map((o) => o.id)).toEqual([2]) }) it('appends a historical record with the parent ids, birth tick, and death cause', () => { const config = resolveWorldConfig() const organisms = new OrganismStore() - organisms.add(organism(1, { energy: 0, parentIds: [7, 9], birthTick: 3 })) + organisms.add(organism(config, 1, { energy: 0, parentIds: [7, 9], birthTick: 3 })) const events = new EventLog(10) events.beginTick() const history = new HistoryStore(10) - new DeathSystem(config, organisms, events, history).update(20) + new DeathSystem(organisms, events, history).update(20) expect(history.list()).toEqual([ - { id: 1, parentIds: [7, 9], birthTick: 3, deathTick: 20, deathCause: 'starvation' } + { + id: 1, + genomeId: 1, + parentIds: [7, 9], + birthTick: 3, + deathTick: 20, + deathCause: 'starvation' + } ]) }) }) diff --git a/packages/simulation/tests/traits.test.ts b/packages/simulation/tests/traits.test.ts new file mode 100644 index 0000000..a14a717 --- /dev/null +++ b/packages/simulation/tests/traits.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { resolveWorldConfig } from '@evo/config' +import { + basalCostOf, + maxEnergyOf, + reproductionCostOf, + reproductionThresholdOf, + sensorCostOf, + sizeFactor +} from '../src/organisms/traits' +import { organismFor, withGenes } from './support/organism' + +const config = resolveWorldConfig() +const founder = organismFor(config, 1).genome + +describe('derived traits at the founder baseline equal their Phase 3 values exactly', () => { + it('size factor is exactly 1', () => { + expect(sizeFactor(founder, config)).toBe(1) + }) + + it('energy capacity is organisms.maxEnergy', () => { + expect(maxEnergyOf(founder, config)).toBe(config.organisms.maxEnergy) + }) + + it('basal cost is organisms.basalCost', () => { + expect(basalCostOf(founder, config)).toBe(config.organisms.basalCost) + }) + + it('sensor cost is organisms.sensorCost', () => { + expect(sensorCostOf(founder, config)).toBe(config.organisms.sensorCost) + }) + + it('reproduction cost is 20 and the threshold is the 60 minEnergy', () => { + expect(reproductionCostOf(founder, config)).toBe(20) + expect(reproductionThresholdOf(founder, config)).toBe(60) + }) + + it('stays exact for non-default baselines too', () => { + const custom = resolveWorldConfig({ + organisms: { radius: 3, maxEnergy: 70, sensorRadius: 80, basalCost: 0.7, sensorCost: 0.15 } + }) + const genome = organismFor(custom, 1).genome + expect(sizeFactor(genome, custom)).toBe(1) + expect(maxEnergyOf(genome, custom)).toBe(70) + expect(basalCostOf(genome, custom)).toBe(0.7) + expect(sensorCostOf(genome, custom)).toBe(0.15) + }) +}) + +describe('capability is not free', () => { + it('a body twice as wide holds four times the energy and burns four times as much', () => { + const big = withGenes(organismFor(config, 1), { 'physical.radius': 10 }).genome + expect(sizeFactor(big, config)).toBe(4) + expect(maxEnergyOf(big, config)).toBe(4 * config.organisms.maxEnergy) + expect(basalCostOf(big, config)).toBe(4 * big.metabolic.basalCost) + }) + + it('a smaller body holds and burns proportionally less', () => { + const small = withGenes(organismFor(config, 1), { 'physical.radius': 2.5 }).genome + expect(sizeFactor(small, config)).toBe(0.25) + expect(maxEnergyOf(small, config)).toBe(config.organisms.maxEnergy / 4) + }) + + it('a longer sensor costs proportionally more to run', () => { + const far = withGenes(organismFor(config, 1), { 'sensory.sensorRadius': 120 }).genome + expect(sensorCostOf(far, config)).toBe(2 * config.organisms.sensorCost) + const near = withGenes(organismFor(config, 1), { 'sensory.sensorRadius': 30 }).genome + expect(sensorCostOf(near, config)).toBe(0.5 * config.organisms.sensorCost) + }) + + it('a parent pays its own investment plus the transfer overhead', () => { + const generous = withGenes(organismFor(config, 1), { + 'reproduction.offspringInvestment': 25 + }).genome + expect(reproductionCostOf(generous, config)).toBe(40) + const overheadFree = resolveWorldConfig({ reproduction: { overheadFraction: 0 } }) + expect(reproductionCostOf(generous, overheadFree)).toBe(25) + }) +}) + +describe('reproductionThresholdOf', () => { + it('is the minEnergy gene when it already covers the cost', () => { + const genome = withGenes(organismFor(config, 1), { 'reproduction.minEnergy': 80 }).genome + expect(reproductionThresholdOf(genome, config)).toBe(80) + }) + + it('never falls below the cost, so paying cannot drive energy negative', () => { + const genome = withGenes(organismFor(config, 1), { + 'reproduction.minEnergy': 10, + 'reproduction.offspringInvestment': 25 + }).genome + expect(reproductionThresholdOf(genome, config)).toBe(40) + }) +}) diff --git a/packages/simulation/tests/world-genomes.test.ts b/packages/simulation/tests/world-genomes.test.ts new file mode 100644 index 0000000..26a5a2c --- /dev/null +++ b/packages/simulation/tests/world-genomes.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/index' +import { genomeToVector } from '../src/genetics/GenomeFactory' + +/** The world wiring of genomes (docs/decisions/0006); Phase 3 behavior is pinned in world.test.ts. */ +describe('founders carry genomes', () => { + const world = World.create({ seed: 1, config: { organisms: { initialCount: 5 } } }) + const snapshot = world.snapshot() + + it('gives every founder its own genome, in birth order', () => { + expect(snapshot.organisms.map((o) => o.genomeId)).toEqual([0, 1, 2, 3, 4]) + }) + + it('makes the genome available by organism id', () => { + for (const organism of snapshot.organisms) { + expect(world.genomeOf(organism.id)?.id).toBe(organism.genomeId) + } + expect(world.genomeOf(9999)).toBeUndefined() + }) + + it('has no parents', () => { + for (const organism of snapshot.organisms) { + expect(world.genomeOf(organism.id)?.parentIds).toEqual([]) + } + }) + + it('starts every founder at the same scalar genes, but with its own brain', () => { + const genomes = snapshot.organisms.map((o) => world.genomeOf(o.id)!) + const first = genomeToVector(genomes[0]!) + for (const genome of genomes) { + expect(genomeToVector(genome)).toEqual(first) + } + const brains = new Set(genomes.map((g) => g.brain.weights.join(','))) + expect(brains.size).toBe(5) + expect(genomes[0]?.brain.weights).toHaveLength(249) + }) + + it('records the founder genome on its birth event', () => { + const born = world.events.list().filter((e) => e.type === 'organism-born') + expect(born.map((e) => (e.type === 'organism-born' ? e.genomeId : -1))).toEqual([0, 1, 2, 3, 4]) + }) + + it('hands out immutable genomes', () => { + const genome = world.genomeOf(snapshot.organisms[0]!.id)! + expect(Object.isFrozen(genome)).toBe(true) + expect(() => { + ;(genome.physical as { maxSpeed: number }).maxSpeed = 1 + }).toThrow(TypeError) + }) +}) + +describe('config baselines reach the genes', () => { + it('starts every founder at the configured baseline', () => { + const world = World.create({ + seed: 3, + config: { + organisms: { initialCount: 4, maxSpeed: 27, sensorRadius: 90, radius: 3, hue: 0.2 }, + reproduction: { cooldown: 12, offspringInvestment: 9 } + } + }) + for (const organism of world.snapshot().organisms) { + const genome = world.genomeOf(organism.id)! + expect(genome.physical.maxSpeed).toBe(27) + expect(genome.sensory.sensorRadius).toBe(90) + expect(genome.physical.radius).toBe(3) + expect(genome.physical.hue).toBe(0.2) + expect(genome.reproduction.cooldown).toBe(12) + expect(genome.reproduction.offspringInvestment).toBe(9) + } + }) + + it('reports the organism’s own radius gene in the snapshot', () => { + const world = World.create({ seed: 3, config: { organisms: { initialCount: 2, radius: 4 } } }) + for (const organism of world.snapshot().organisms) expect(organism.radius).toBe(4) + }) +}) + +describe('children get new genomes', () => { + const world = World.create({ + seed: 42, + config: { + environment: { width: 100, height: 100 }, + organisms: { + initialCount: 2, + initialEnergy: 100, + maturityAge: 0.05, + senescenceAge: 1000, + maxAge: 2000 + }, + resources: { initialCount: 0, spawnRate: 0 }, + reproduction: { + minEnergy: 60, + offspringInvestment: 10, + overheadFraction: 0, + cooldown: 5, + searchRadius: 1000 + } + } + }) + world.run(1) + const snapshot = world.snapshot() + const child = snapshot.organisms[2]! + + it('allocates the next genome id and links it to both parent genomes', () => { + expect(child.genomeId).toBe(2) + expect(world.genomeOf(child.id)?.parentIds).toEqual([0, 1]) + }) + + it('copies the mother’s genes while inheritance is not yet heritable', () => { + const mother = world.genomeOf(snapshot.organisms[0]!.id)! + const childGenome = world.genomeOf(child.id)! + expect(genomeToVector(childGenome)).toEqual(genomeToVector(mother)) + expect(childGenome.brain.weights).toEqual(mother.brain.weights) + expect(childGenome).not.toBe(mother) + }) + + it('records the child genome on its birth event', () => { + const born = world.events + .list() + .find((e) => e.type === 'organism-born' && e.organismId === child.id) + expect(born).toMatchObject({ genomeId: 2, parentIds: [0, 1] }) + }) + + it('stays valid', () => { + expect(world.validate()).toEqual({ ok: true, errors: [] }) + }) +}) + +describe('genome identity over a long run', () => { + const world = World.create({ + seed: 1, + validateEveryTicks: 250, + config: { history: { maxEvents: 200_000 } } + }) + world.run(4000) + + it('never repeats a genome id among living organisms', () => { + const ids = world.snapshot().organisms.map((o) => o.genomeId) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('keeps the genome id on the historical record of an organism that died', () => { + const records = world.historicalOrganisms() + expect(records.length).toBeGreaterThan(0) + const birthGenomes = new Map() + for (const event of world.events.list()) { + if (event.type === 'organism-born') birthGenomes.set(event.organismId, event.genomeId) + } + for (const record of records) { + expect(Number.isInteger(record.genomeId)).toBe(true) + expect(record.genomeId).toBe(birthGenomes.get(record.id)) + } + }) + + it('keeps every living genome valid', () => { + expect(world.validate()).toEqual({ ok: true, errors: [] }) + }) +}) + +describe('genomes are part of the state hash', () => { + it('differs when only the founders’ brains differ', () => { + const a = World.create({ seed: 1, config: { organisms: { initialCount: 3 } } }) + const b = World.create({ + seed: 1, + config: { organisms: { initialCount: 3 }, genetics: { brain: { initialWeightStd: 0.5 } } } + }) + expect(a.stateHash()).not.toBe(b.stateHash()) + }) + + it('is reproducible for the same seed and configuration', () => { + const a = World.create({ seed: 8, config: { organisms: { initialCount: 3 } } }) + const b = World.create({ seed: 8, config: { organisms: { initialCount: 3 } } }) + a.run(300) + b.run(300) + expect(a.stateHash()).toBe(b.stateHash()) + }) +})