Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/decisions/0006-genetics-and-inheritance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions docs/simulation/determinism.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
| ----- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand Down Expand Up @@ -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):

Expand Down
2 changes: 1 addition & 1 deletion docs/simulation/update-order.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EntityId } from './entity'
import type { GenomeId } from './genome'
import type { DeathCause } from './world'
import type { Vec2 } from './math'

Expand All @@ -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<Vec2>
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/snapshots.ts
Original file line number Diff line number Diff line change
@@ -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<Vec2>
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/contracts/tests/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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)
}
})
Expand Down
4 changes: 3 additions & 1 deletion packages/simulation/src/entities/OrganismStore.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/simulation/src/genetics/GenomeFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
43 changes: 43 additions & 0 deletions packages/simulation/src/organisms/traits.ts
Original file line number Diff line number Diff line change
@@ -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))
}
2 changes: 1 addition & 1 deletion packages/simulation/src/serialization/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
8 changes: 4 additions & 4 deletions packages/simulation/src/systems/DeathSystem.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
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
* loop iteration (docs/decisions/0005).
*/
export class DeathSystem {
constructor(
private readonly config: WorldConfig,
private readonly organisms: OrganismStore,
private readonly events: EventLog,
private readonly history: HistoryStore
Expand All @@ -23,14 +22,15 @@ 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

this.organisms.remove(organism.id)
this.history.append({
id: organism.id,
genomeId: organism.genome.id,
parentIds: organism.parentIds,
birthTick: organism.birthTick,
deathTick: tick,
Expand Down
2 changes: 1 addition & 1 deletion packages/simulation/src/systems/DecisionSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 5 additions & 5 deletions packages/simulation/src/systems/InteractionSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand All @@ -27,15 +28,14 @@ export class InteractionSystem {

update(tick: number): void {
const consumed = new Set<EntityId>()
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))
Expand Down
19 changes: 8 additions & 11 deletions packages/simulation/src/systems/MetabolismSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading