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