From 79145ff7ba00b8c65ae19d096ec6991b3c08d34c Mon Sep 17 00:00:00 2001 From: Bryant Ejorh <191651927+CodeByBryant@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:29:03 -0700 Subject: [PATCH] docs(genetics): design note for genetics and inheritance (ADR 0006) Record the Phase 4 design before any code: a per-organism immutable genome (physical, sensory, metabolic, behavioral, reproduction, brain, learning), table-driven per-kind mutation, explicit recombination (blend crossover for continuous genes, per-block segment crossover for neural weights so both parents' brains contribute), validate/repair/compare operations, a lineage store that outlives organisms, and learning inheritance as a pure function wired in Phase 5. Capability is coupled to cost (body-size-derived energy capacity and basal cost, sensor cost scaling with sensor radius, reproduction cost scaling with offspring investment) so no gene is a free lunch; at the baseline genome every quantity equals its Phase 3 value, which lets the behavior-preserving PR prove it changes nothing. Also adds docs/genetics/genome.md (gene tables, ranges, operators, weight layout, lineage API), hash schema v3 layout in determinism.md, the genome-reading pipeline notes in update-order.md, and brings the configuration.md field table up to date (it was missing the Phase 3 fields). Co-Authored-By: Claude Sonnet 5.5 --- .../0006-genetics-and-inheritance.md | 152 ++++++++++++++++ docs/genetics/genome.md | 163 ++++++++++++++++++ docs/simulation/configuration.md | 74 +++++--- docs/simulation/determinism.md | 13 +- docs/simulation/update-order.md | 46 ++--- 5 files changed, 402 insertions(+), 46 deletions(-) create mode 100644 docs/decisions/0006-genetics-and-inheritance.md create mode 100644 docs/genetics/genome.md diff --git a/docs/decisions/0006-genetics-and-inheritance.md b/docs/decisions/0006-genetics-and-inheritance.md new file mode 100644 index 0000000..9d3644e --- /dev/null +++ b/docs/decisions/0006-genetics-and-inheritance.md @@ -0,0 +1,152 @@ +# 0006: Genetics and inheritance (genomes, recombination, mutation, lineage) + +Status: **Accepted** (approved by the project owner with the Phase 4 plan, 2026-10-23) + +## Problem + +Phase 3 made the population self-sustaining, but every organism still shares one set of traits read from global config, and a child is a fresh organism that inherits nothing. Phase 4 (roadmap, Nov 30-Dec 27 2026) makes inheritance real and testable: each organism carries a **genome**; a child's genome is produced from both parents by explicit recombination followed by bounded, per-gene mutation; lineage records outlive their organisms; genomes can be validated and compared. + +Three forks were resolved with the project owner before this ADR: + +- **Brain and learning genes are real now.** The roadmap's genome includes `brain` and `learning` genes, and the exit criteria require inheriting neural weights, but the brain itself is Phase 5. The genome therefore carries a fixed-architecture weight vector and learning parameters that are recombined, mutated, validated and compared, while **no system reads them until Phase 5**. Dimensions come from config so Phase 5 can set the real observation size. +- **All phenotype groups are live.** Every gene in `physical`, `sensory`, `metabolic`, `behavioral` and `reproduction` is read by a real system, so mutation changes behavior immediately. Costs are coupled to capability so that no gene is a free lunch (see below). +- **Learning inheritance is a pure function now, wired in Phase 5.** All three modes are implemented and proven on synthetic learned weights. No runtime brain state is added before a brain exists. + +## Decision + +### The genome + +`Genome` (new `@evo/contracts` file `genome.ts`, roadmap 4.1): `{ version: 1, id: GenomeId, parentIds: GenomeId[], physical, sensory, metabolic, behavioral, reproduction, brain, learning }`. A genome is **immutable** and belongs to exactly one organism. `GenomeId` comes from its own monotonic counter (hashed as `nextGenomeId`), separate from `EntityId`, so genome identity never depends on how many resources have spawned. Founders receive ids `0..n-1`; every birth allocates a new id. + +The full gene list, default ranges and mutation parameters are in [docs/genetics/genome.md](../genetics/genome.md). Summary: + +| Group | Genes | Read by (Phase 4) | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `physical` | `radius`, `maxSpeed`, `maxTurnRate`, `maturityAge`, `senescenceAge`, `maxAge`, `hue` | Movement, Interaction, Death; `hue` carried (rendering is Phase 7) | +| `sensory` | `sensorRadius` | Perception, Metabolism | +| `metabolic` | `basalCost`, `movementCost`, `turningCost` | Metabolism | +| `behavioral` | `wanderJitter` | Decision | +| `reproduction` | `minEnergy`, `offspringInvestment`, `cooldown`, `searchRadius` | Reproduction | +| `brain` | architecture `{inputSize, hiddenSize, memorySize, outputSize}` plus a flat `weights` vector | carried (Phase 5) | +| `learning` | `learningRate`, `discountFactor`, `eligibilityTraceDecay`, `explorationRate`, `riskTolerance`, `noveltyPreference`, `persistence` | carried (Phase 5) | + +The roadmap (3.4) makes the life-stage ages genome-controlled; Phase 3 had to place them in `OrganismConfig` because no genome existed. They move into `physical` here. The juvenile multipliers (`juvenileSizeScale`, `juvenileSpeedScale`, `juvenileMetabolicScale`) describe what a juvenile _is_, not a per-organism trait, and stay world-level config. + +### Founders and the config baseline + +Founders get **identical** genomes built from the existing config values: `organisms.*` and `reproduction.*` keep their meaning as the founder baseline, which keeps every Phase 3 default meaningful. Brain weights are drawn from the `genetics` stream (Gaussian, std `genetics.brain.initialWeightStd`), one founder at a time in id order. Variation enters the population only at births. + +### Capability is not free: derived quantities + +If capability and cost were independent genes, selection would simply push every capability to its maximum and every cost to its minimum. Instead the following are **derived, not genes**, each equal to its Phase 3 value at the baseline genome (all by multiplication and division; `**` and `Math.pow` are banned in `simulation/src`): + +```text +sizeFactor = (radius / baseRadius) * (radius / baseRadius) +maxEnergy = baseMaxEnergy * sizeFactor +basalCost applied = genes.basalCost * sizeFactor +sensorCost = baseSensorCost * (sensorRadius / baseSensorRadius) +parent pays = offspringInvestment * (1 + reproduction.overheadFraction) +child energy = min(investmentA + investmentB, child maxEnergy) +``` + +The metabolism formula from ADR 0005 is otherwise unchanged and reads the organism's own `movementCost`, `turningCost` and derived terms. `movementCost` and `turningCost` are already coupled to _realized_ speed and turn (they multiply `speed^2` and `lastTurnMagnitude^2`), so a faster or more agile organism pays more when it actually moves. + +This **replaces** the config fields `reproduction.energyCost` and `reproduction.offspringEnergy` with `reproduction.offspringInvestment` (the per-parent baseline) and `reproduction.overheadFraction` (energy lost in the transfer). With investment `12.5` and overhead `0.6`, a parent pays `20` and the child receives `25`, exactly Phase 3's values. There are no external users of the old fields, so no compatibility shim is kept. + +### Mutation + +`genetics/Mutation.ts` is **table-driven** over a fixed-order `GENE_SPECS` array (never `Object.keys`; the determinism lint forbids it). Each entry: `{ path, kind: 'float' | 'int' | 'bool' | 'circular', min, max, mutationRate, mutationStd, correlationGroup? }`. A different operator applies per kind; they are not one arbitrary formula (roadmap 4.3): + +- **gaussian** (floats): add `N(0, mutationStd * (max - min))`, then clamp to `[min, max]`. +- **point** (integers): step by `+-1` and clamp. +- **bit-flip** (booleans). +- **circular** (`hue`): Gaussian in `[0, 1)` turns, wrapped. +- **trait-shift** (correlated traits): genes sharing a `correlationGroup` receive one shared normalized delta, scaled into each gene's own range. The mechanism is required by the roadmap ("where appropriate"); the production table declares no group yet, and tests exercise it with a synthetic table. +- **learning-parameter**: `learningRate` mutates in log space; the other learning genes are bounded Gaussians within `[0, 1]`. +- **brain-weight**: each weight mutates independently with probability `genetics.brain.weightMutationRate` by `N(0, weightMutationStd)`. + +Global knobs: `genetics.mutationRateScale` multiplies every rate and `genetics.mutationStrengthScale` multiplies every std; `0` disables mutation entirely. Integer, boolean and circular behavior is proven with synthetic spec tables because the production table has no integer or boolean gene. + +### Recombination + +```ts +interface RecombinationStrategy { + combine(mother: Genome, father: Genome, random: RandomSource): Genome +} +``` + +The two partners are symmetric, so "mother" is the parent with the lower organism id: the choice is deterministic and the draw order is fixed. The default strategy: + +- integers and booleans: uniform pick from either parent per gene; +- continuous genes: **blend crossover** (BLX-alpha, alpha = `genetics.blendAlpha`, default `0.25`): the child value is drawn uniformly from `[lo - alpha*d, hi + alpha*d]` where `d = |a - b|`, then clamped. Identical parents give an identical child; +- circular genes: the same blend along the shortest arc; +- neural weights: **per-block single-point crossover** over each documented weight block (input-hidden, memory-hidden, and so on), with a random choice of which parent supplies the prefix. Every block therefore contains weights from both parents, so both brains actually contribute (roadmap 4.4); +- then mutation is applied to the recombined result. + +Every gene consumes a fixed number of draws per birth regardless of configuration: the mutation gate draw is always taken and merely not applied when it fails, so changing a rate never shifts later draws within the stream. All of this draws from the `genetics` stream (reserved since Phase 2). Child spawn jitter and heading stay on the `reproduction` stream, so existing reproduction-stream sequences are untouched. + +### Constraints + +`validateGenome(genome, specs): ValidationResult` checks: every scalar gene within `[min, max]`; integers are integers; booleans are 0/1; brain architecture equals the configured architecture and the weight count equals the architecture's weight count (all finite); `learningRate >= 0`; `discountFactor` in `[0, 1]`; `offspringInvestment > 0`; and the ordering `maturityAge < senescenceAge < maxAge`. The default ranges of those three genes **do not overlap**, so the ordering holds by construction for any recombination or mutation; config validation rejects overlapping ranges. `repairGenome(genome, specs): Genome` is a pure clamp provided for tooling, but `ReproductionSystem` never calls it (roadmap 4.5: prefer rejecting invalid genomes in development). `World.validate()` rejects any living organism whose genome is invalid (roadmap: "No invalid genome"). + +### Comparison + +`genomeDistance(a, b)` returns per-group distances in `[0, 1]` plus a `total` (the unweighted mean of the seven group distances). Scalar genes are normalized by range width, circular genes by shortest arc, and brain weights by RMS difference divided by `2 * initialWeightStd` (clamped to 1). It is symmetric and `0` for identical genomes. Phase 6 speciation reuses it. + +### Lineage + +`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. + +### Learning inheritance + +```ts +type LearningInheritanceMode = 'baldwinian' | 'partial' | 'lamarckian' +``` + +Config `genetics.learningInheritance: { mode, fraction }`, default `partial` / `0.15` (roadmap 4.7). A pure function `inheritedBrainWeights(genomeWeights, learnedWeights, mode, fraction)` returns `genomeWeights` for Baldwinian, `learnedWeights` for Lamarckian, and `genome + fraction * (learned - genome)` for partial. In Phase 4 `ReproductionSystem` passes the genome weights only (nothing has learned); Phase 5 adds the learned overlay. + +### Spatial index cell sizes + +The resource and organism `SpatialHash` cell sizes were `organisms.sensorRadius` and `reproduction.searchRadius`. They become the upper bound of those genes' ranges. Query correctness never depends on cell size, only efficiency does. + +### State hash + +`HASH_SCHEMA_VERSION` 2 -> 3. The ids section appends `nextGenomeId`; each organism entry appends its genome (id, parent ids, every gene in `GENE_SPECS` order as float64, brain architecture and weights). The lineage store and history are not hashed. Exact layout: [determinism.md](../simulation/determinism.md#73-canonical-stream-schema-versions). Goldens are regenerated in the PR that bumps the version and again in the PR that makes inheritance live, with the reason stated in each. + +## Alternatives considered + +- **Defer brain and learning genes to Phase 5.** Rejected: the exit criteria ("inherit compatible neural weights", "learning inheritance mode configurable") could not be met. +- **Carry runtime brain state now** so the Lamarckian path is testable end to end. Rejected: doubles per-organism brain memory for state nothing updates until Phase 5; the pure function plus synthetic-weight tests prove the same thing. +- **Make every cost coefficient a free gene.** Rejected: selection drives costs to their minimum and capabilities to their maximum. Costs are coupled to capability instead, and the remaining metabolic genes have bounded, narrow ranges. +- **Carry only, wire nothing / a minimal live subset.** Rejected by the owner: a genome nothing reads cannot demonstrate inheritance. +- **One generic mutation formula** (the old prototype's approach). Rejected by the roadmap (4.3). +- **Evolving network topology.** Deferred (roadmap 4.2): all organisms share one architecture so weight vectors are always compatible. +- **Shared/interned genomes for clones.** Rejected: one immutable genome per organism is simpler, and population sizes make the memory irrelevant. +- **Maintaining `descendantCount` per birth.** Rejected: the ancestor set of a child in a sexual lineage approaches the whole history, making each birth O(history). +- **Reusing `EntityId` for genome ids.** Rejected: coupling genome identity to resource spawning is surprising and makes ids unstable under unrelated config changes. + +## Simulation consequences + +Behavior changes in two steps. First, organisms carry genomes but children are blends of identical founders with mutation off: the dynamics are **unchanged** from Phase 3 (a hard gate, see Testing). Second, recombination and mutation are switched on, and populations drift. Because every capability is bounded and cost-coupled, drift is expected to stay within the default ranges, but a population that evolves cheaper metabolism could still grow toward `maxPopulation`; the 50,000-tick stability scenario from Phase 3 must still hold with default mutation, and default ranges or `mutationStrengthScale` are tuned (not the test) if it does not. + +## Determinism impact + +The `genetics` stream becomes active (`stream-usage.test.ts` drops it from the reserved list in the PR that uses it). Streams are isolated, so `world`, `environment` and `reproduction` sequences are unaffected. Gene iteration uses fixed arrays only. Hash schema v3 as above. + +## Persistence impact + +None yet (no save/load until Phase 8). Genomes are flat numeric data plus ids and the lineage store is integer records, so both serialize trivially. Brain dimensions are part of the genome, so changing the default architecture later is a hash and golden change but needs no migration while the project is pre-release. + +## Performance impact + +One genome (a few hundred numbers with the default 249 brain weights) is allocated per birth, about 1,200 births per 100,000 ticks in the default scenario. Systems read genes from the organism instead of config: negligible. Lineage queries are on demand. Phase 9 owns any optimization (for example structure-of-arrays genes). + +## Testing strategy + +Pure-function unit tests mirror the roadmap's genetics list: mutation stays bounded; parent traits are not mutated; both parents contribute; booleans inherit correctly; integers stay integers; brain weights preserve architecture; learning parameters mutate correctly; all three learning modes behave as specified. Config validation tests for every new field and cross-field rule. **Equivalence gate (PR 3):** with organisms carrying genomes and mutation off, every pinned Phase 3 scenario value is unchanged (Scenario A, extinction tick, Scenario C, the 50,000-tick stability band). **Stability gate (PR 4):** the stability band still holds with default mutation. Long-run suite: genomes valid every tick over 100,000 ticks, chunked and stepped runs hash-identical with mutation active, lineage survives parent death and extinction. + +## Rollback plan + +Each PR is independently revertable. The behavior-preserving PR (organisms carry genomes) can ship and stay even if inheritance is later disabled: setting `mutationRateScale` to `0` makes children blends of their parents without drift, and reverting only the "heritable genomes" PR restores genome-carrying organisms that copy a parent's genome. diff --git a/docs/genetics/genome.md b/docs/genetics/genome.md new file mode 100644 index 0000000..0176527 --- /dev/null +++ b/docs/genetics/genome.md @@ -0,0 +1,163 @@ +# Genome reference + +This is the reference for the genome introduced in [ADR 0006](../decisions/0006-genetics-and-inheritance.md): gene tables, defaults, operators, the brain weight layout, validation rules and the lineage API. The ADR holds the reasoning; this file holds the numbers. + +All defaults are provisional. They are tuned by the engine's scenario tests, and determinism tests pin their configuration explicitly, so changing a default never changes a golden hash. + +## Genes + +Founders start at the **baseline** (the existing `organisms.*` / `reproduction.*` config values). A gene's **range** bounds every genome ever produced. `rate` is the probability that the gene mutates in a birth and `std` is the mutation standard deviation as a fraction of the range width, both before the global `mutationRateScale` / `mutationStrengthScale`. + +| Gene | Kind | Baseline (config source) | Range | Rate | Std | Read by | +| ---------------------------------- | ----------- | ----------------------------------------- | ------------- | ---- | ---- | ------------------------------------------------------------ | +| `physical.radius` | float | 5 (`organisms.radius`) | 2.5 - 10 | 0.1 | 0.05 | Interaction (capture radius), size-derived costs, snapshot | +| `physical.maxSpeed` | float | 20 (`organisms.maxSpeed`) | 10 - 40 | 0.1 | 0.05 | Movement | +| `physical.maxTurnRate` | float | 3 (`organisms.maxTurnRate`) | 1.5 - 6 | 0.1 | 0.05 | Movement | +| `physical.maturityAge` | float | 90 (`organisms.maturityAge`) | 45 - 180 | 0.1 | 0.05 | life stage (Movement, Interaction, Metabolism, Reproduction) | +| `physical.senescenceAge` | float | 510 (`organisms.senescenceAge`) | 255 - 560 | 0.1 | 0.05 | life stage | +| `physical.maxAge` | float | 600 (`organisms.maxAge`) | 570 - 1200 | 0.1 | 0.05 | Death | +| `physical.hue` | circular | 0.55 (`organisms.hue`) | [0, 1) turns | 0.1 | 0.05 | carried (rendering, Phase 7) | +| `sensory.sensorRadius` | float | 60 (`organisms.sensorRadius`) | 30 - 120 | 0.1 | 0.05 | Perception; sensor cost | +| `metabolic.basalCost` | float | 0.5 (`organisms.basalCost`) | 0.3 - 0.8 | 0.1 | 0.05 | Metabolism (scaled by body size) | +| `metabolic.movementCost` | float | 0.005 (`organisms.movementCost`) | 0.003 - 0.008 | 0.1 | 0.05 | Metabolism | +| `metabolic.turningCost` | float | 0.05 (`organisms.turningCost`) | 0.03 - 0.08 | 0.1 | 0.05 | Metabolism | +| `behavioral.wanderJitter` | float | 1.5 (`organisms.wanderJitter`) | 0.75 - 3 | 0.1 | 0.05 | Decision | +| `reproduction.minEnergy` | float | 60 (`reproduction.minEnergy`) | 40 - 90 | 0.1 | 0.05 | Reproduction (eligibility) | +| `reproduction.offspringInvestment` | float | 12.5 (`reproduction.offspringInvestment`) | 6 - 25 | 0.1 | 0.05 | Reproduction (energy given to the child) | +| `reproduction.cooldown` | float | 30 (`reproduction.cooldown`) | 15 - 60 | 0.1 | 0.05 | Reproduction | +| `reproduction.searchRadius` | float | 50 (`reproduction.searchRadius`) | 25 - 100 | 0.1 | 0.05 | Reproduction (partner search) | +| `learning.learningRate` | float (log) | 0.05 (`genetics.learning`) | 0.001 - 0.5 | 0.1 | 0.05 | carried (Phase 5) | +| `learning.discountFactor` | float | 0.95 (`genetics.learning`) | 0 - 1 | 0.1 | 0.05 | carried | +| `learning.eligibilityTraceDecay` | float | 0.9 (`genetics.learning`) | 0 - 1 | 0.1 | 0.05 | carried | +| `learning.explorationRate` | float | 0.1 (`genetics.learning`) | 0 - 1 | 0.1 | 0.05 | carried | +| `learning.riskTolerance` | float | 0.5 (`genetics.learning`) | 0 - 1 | 0.1 | 0.05 | carried | +| `learning.noveltyPreference` | float | 0.5 (`genetics.learning`) | 0 - 1 | 0.1 | 0.05 | carried | +| `learning.persistence` | float | 0.5 (`genetics.learning`) | 0 - 1 | 0.1 | 0.05 | carried | + +The ranges of `maturityAge`, `senescenceAge` and `maxAge` are disjoint and ordered so that `maturityAge < senescenceAge < maxAge` holds for every genome by construction. Config validation rejects overrides that make them overlap. + +Gene order in `GENE_SPECS` is fixed (the order above) and is the order used for mutation draws, the state hash and `genomeDistance`. + +An organism can legitimately inherit a combination that cannot reproduce (for example a small body whose derived `maxEnergy` is below its `minEnergy`). That is natural selection, not an invalid genome. + +## Derived quantities (not genes) + +```text +sizeFactor = (radius / baseRadius)^2 (computed by multiplication) +maxEnergy = baseMaxEnergy * sizeFactor (base = organisms.maxEnergy) +basal cost = metabolic.basalCost * sizeFactor +sensor cost = baseSensorCost * (sensorRadius / baseSensorRadius) (base = organisms.sensorCost) +parent pays = offspringInvestment * (1 + reproduction.overheadFraction) +child energy = min(investmentA + investmentB, child maxEnergy) +``` + +At the baseline genome each quantity equals its Phase 3 value, which is what lets the behavior-preserving PR prove it changes nothing. Juvenile multipliers (`juvenileSizeScale`, `juvenileSpeedScale`, `juvenileMetabolicScale`) remain world-level config and apply on top. + +## Brain genome + +`brain = { architecture: { inputSize, hiddenSize, memorySize, outputSize }, weights: number[] }`. Every organism in a world has the same architecture (set by `genetics.brain`), so weight vectors are always compatible; topology does not evolve (roadmap 4.2). The defaults `16 / 8 / 4 / 5` are placeholders that Phase 5 owns: it will set the real observation size. Output size 5 matches the roadmap's `Action` (thrust, turn, strafe, interact, explore). + +`weights` is one flat vector made of seven consecutive **blocks**. The layout is stable and documented; reordering it is a hash-schema change. + +| # | Block | Shape (rows x cols) | Default length | +| --- | ---------------- | ------------------- | -------------- | +| 1 | `inputToHidden` | hidden x input | 128 | +| 2 | `memoryToHidden` | hidden x memory | 32 | +| 3 | `hiddenBias` | hidden | 8 | +| 4 | `hiddenToMemory` | memory x hidden | 32 | +| 5 | `memoryBias` | memory | 4 | +| 6 | `hiddenToOutput` | output x hidden | 40 | +| 7 | `outputBias` | output | 5 | + +Default total: 249 weights. Crossover operates per block (see below). No Phase 4 code interprets these numbers beyond their block boundaries. + +## Operators + +**Mutation** (`genetics/Mutation.ts`), applied per gene in `GENE_SPECS` order after recombination. Each gene always takes its gate draw, whether or not mutation then applies. + +| Kind / gene type | Operator | +| ----------------------- | ------------------------------------------------------------------------------------------------- | +| float | gaussian: add `N(0, std * (max - min))`, clamp to range | +| integer | point: step `+-1`, clamp | +| boolean | bit-flip | +| circular (`hue`) | gaussian in turns, wrapped into `[0, 1)` | +| correlated group | trait-shift: one shared normalized delta applied to every gene in the group, scaled to each range | +| `learning.learningRate` | gaussian in log space, clamp | +| brain weight | each weight independently with probability `weightMutationRate`: add `N(0, weightMutationStd)` | + +**Recombination** (`genetics/Recombination.ts`): `mother` is the parent with the lower organism id. + +| Gene type | Operator | +| ----------------- | -------------------------------------------------------------------------------------------------------- | +| integer / boolean | uniform pick from either parent | +| float | blend crossover, `child ~ U[lo - a*d, hi + a*d]` with `d = abs(A - B)`, `a = blendAlpha`, then clamp | +| circular | the same blend along the shortest arc, wrapped | +| brain weights | per block: one random cut point in `[1, len - 1]`, one random choice of which parent supplies the prefix | + +Because each block is cut strictly inside, every block of every child contains weights from both parents. + +## Validation + +`validateGenome(genome, specs)` reports every violation (it never repairs): + +- each scalar gene is finite and within its `[min, max]`; integer genes are integers; boolean genes are 0 or 1; +- `maturityAge < senescenceAge < maxAge`; +- `learningRate >= 0`, `discountFactor` in `[0, 1]`, `offspringInvestment > 0`; +- brain architecture equals the configured architecture; weight count equals the architecture's total; every weight is finite; +- `id` is a nonnegative integer below `nextGenomeId`; `parentIds` are nonnegative integers below `id`. + +`repairGenome(genome, specs)` is a pure clamp for tooling. The engine never calls it. + +## Comparison + +`genomeDistance(a, b)` returns `{ total, physical, sensory, metabolic, behavioral, reproduction, brain, learning }`, each in `[0, 1]`. Scalar genes: `|a - b| / (max - min)`, circular genes: shortest arc / 0.5, group distance: mean over the group's genes. Brain: RMS weight difference / `(2 * initialWeightStd)`, clamped to 1. `total` is the unweighted mean of the seven groups. Symmetric; `0` for identical genomes. + +## Learning inheritance + +`inheritedBrainWeights(genomeWeights, learnedWeights, mode, fraction)`: + +| Mode | Result | +| ------------ | ---------------------------------------------------- | +| `baldwinian` | `genomeWeights` (learning is never inherited) | +| `lamarckian` | `learnedWeights` (all learning is inherited) | +| `partial` | `genome + fraction * (learned - genome)`, per weight | + +Default `partial` / `0.15`. In Phase 4 nothing learns, so `ReproductionSystem` passes the genome weights only. + +## Lineage + +`LineageStore` (`World.lineage`) holds `LineageRecord { genomeId, parentGenomeIds, organismIds, createdAtTick, descendantCount }` for every genome ever created, including those whose organisms have died. + +| Query | Result | +| ------------------------------ | ---------------------------------------------------------------------- | +| `record(id)` | the record (with `descendantCount` computed on demand), or `undefined` | +| `parentsOf(id)` | parent genome ids, ascending | +| `ancestorsOf(id)` | all ancestors (transitive), ascending | +| `descendantsOf(id)` | all descendants (transitive), ascending | +| `commonAncestor(a, b)` | the shared ancestor with the greatest id (most recent), or `null` | +| `founders()` / `isFounder(id)` | genomes with no parents | +| `isLineageExtinct(id)` | `true` if neither the genome nor any descendant has a living organism | + +Every genome has two parents except founders. Traversals are on demand and de-duplicate shared ancestors. The store is not hashed. + +## Configuration + +New and changed config (full table in [configuration.md](../simulation/configuration.md)): + +```ts +genetics: { + mutationRateScale: 1, // multiplies every rate; 0 disables mutation + mutationStrengthScale: 1, // multiplies every std; 0 disables mutation + blendAlpha: 0.25, + brain: { + inputSize: 16, hiddenSize: 8, memorySize: 4, outputSize: 5, + initialWeightStd: 0.3, weightMutationRate: 0.05, weightMutationStd: 0.1 + }, + learning: { learningRate: 0.05, discountFactor: 0.95, eligibilityTraceDecay: 0.9, + explorationRate: 0.1, riskTolerance: 0.5, noveltyPreference: 0.5, persistence: 0.5 }, + learningInheritance: { mode: 'partial', fraction: 0.15 }, + genes: {} // optional per-gene { min, max, mutationRate, mutationStd } overrides, keyed by path +} +``` + +plus `organisms.hue` (founder baseline), `reproduction.offspringInvestment` and `reproduction.overheadFraction`, which replace `reproduction.energyCost` and `reproduction.offspringEnergy`. diff --git a/docs/simulation/configuration.md b/docs/simulation/configuration.md index ae109c6..33f2265 100644 --- a/docs/simulation/configuration.md +++ b/docs/simulation/configuration.md @@ -22,29 +22,55 @@ Seeds are validated separately with `validateSeed`: an integer in `[0, 429496729 Units: time is in **seconds** (one tick advances time by `timestep`), distance in world units, energy in arbitrary units. -| Field | Rule | Meaning | -| ------------------------------ | --------------------------------------- | ------------------------------------------------------- | -| `timestep` | > 0 | Seconds of simulated time per tick | -| `maxPopulation` | integer, > 0 | Population ceiling | -| `extinctionPolicy` | `'stop'` (only value implemented) | What happens when the population reaches zero | -| `environment.width`, `.height` | > 0 | World rectangle | -| `organisms.initialCount` | integer, >= 0, <= `maxPopulation` | Founders created at tick 0 | -| `organisms.initialEnergy` | > 0, <= `maxEnergy` | Starting energy | -| `organisms.maxEnergy` | > 0 | Capacity; overflow is wasted | -| `organisms.radius` | > 0, < half the smaller world dimension | Body radius | -| `organisms.maxSpeed` | >= 0 | Units per second | -| `organisms.maxTurnRate` | >= 0 | Radians per second | -| `organisms.sensorRadius` | > 0 | Perception distance (also the spatial grid cell size) | -| `organisms.wanderJitter` | >= 0 | Wander heading change, radians per second | -| `organisms.basalCost` | >= 0 | Energy per second for being alive | -| `organisms.movementCost` | >= 0 | Energy per second per squared unit of speed | -| `organisms.maxAge` | > 0 | Seconds until death by age | -| `resources.initialCount` | integer, >= 0, <= `maxCount` | Resources at tick 0 | -| `resources.maxCount` | integer, >= 0 | Cap on simultaneous resources | -| `resources.spawnRate` | >= 0 | Expected new resources per second | -| `resources.energyValue` | > 0 | Energy one resource provides | -| `resources.radius` | > 0, < half the smaller world dimension | Resource radius | -| `history.maxEvents` | integer, >= 0 | Retained events (`0` keeps none) | -| `history.eventDetail` | `'essential'` or `'verbose'` | Whether per-tick movement and energy events are emitted | +| Field | Rule | Meaning | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `timestep` | > 0 | Seconds of simulated time per tick | +| `maxPopulation` | integer, > 0 | Population ceiling (births are refused at the cap) | +| `extinctionPolicy` | `'stop'` (only value implemented) | What happens when the population reaches zero | +| `environment.width`, `.height` | > 0 | World rectangle | +| `organisms.initialCount` | integer, >= 0, <= `maxPopulation` | Founders created at tick 0 | +| `organisms.initialEnergy` | > 0, <= `maxEnergy` | Founders' starting energy | +| `organisms.maxEnergy` | > 0 | Founder energy capacity (an organism's capacity scales with its body size gene); overflow is wasted | +| `organisms.radius` | > 0, < half the smaller world dimension | **Founder baseline** body radius | +| `organisms.maxSpeed` | >= 0 | **Founder baseline**, units per second | +| `organisms.maxTurnRate` | >= 0 | **Founder baseline**, radians per second | +| `organisms.sensorRadius` | > 0 | **Founder baseline** perception distance | +| `organisms.wanderJitter` | >= 0 | **Founder baseline** wander heading change, radians per second | +| `organisms.basalCost` | >= 0 | **Founder baseline** energy per second for being alive | +| `organisms.movementCost` | >= 0 | **Founder baseline** energy per second per squared unit of speed | +| `organisms.turningCost` | >= 0 | **Founder baseline** energy per second per squared radian turned | +| `organisms.sensorCost` | >= 0 | Energy per second for sensing at the baseline `sensorRadius` (scales with an organism's own radius) | +| `organisms.maturityAge` | > 0, < `senescenceAge` | **Founder baseline** seconds until a juvenile becomes mature | +| `organisms.senescenceAge` | > `maturityAge`, < `maxAge` | **Founder baseline** seconds until a mature organism becomes senescent | +| `organisms.maxAge` | > `senescenceAge` | **Founder baseline** seconds until death by age | +| `organisms.hue` | [0, 1) | **Founder baseline** hue in turns (cosmetic) | +| `organisms.juvenileSizeScale` | (0, 1] | Juvenile capture radius and energy capacity multiplier | +| `organisms.juvenileSpeedScale` | (0, 1] | Juvenile `maxSpeed` multiplier | +| `organisms.juvenileMetabolicScale` | (0, 1] | Juvenile metabolic cost multiplier | +| `resources.initialCount` | integer, >= 0, <= `maxCount` | Resources at tick 0 | +| `resources.maxCount` | integer, >= 0 | Cap on simultaneous resources | +| `resources.spawnRate` | >= 0 | Expected new resources per second | +| `resources.energyValue` | > 0 | Energy one resource provides | +| `resources.radius` | > 0, < half the smaller world dimension | Resource radius | +| `reproduction.minEnergy` | > 0 | **Founder baseline** energy required to attempt reproduction | +| `reproduction.offspringInvestment` | > 0 | **Founder baseline** energy each parent gives the child; `parent pays = investment * (1 + overheadFraction)` | +| `reproduction.overheadFraction` | >= 0 | Fraction of the investment lost in the transfer | +| `reproduction.cooldown` | >= 0 | **Founder baseline** seconds before a parent may reproduce again | +| `reproduction.searchRadius` | > 0 | **Founder baseline** distance within which a partner is sought | +| `history.maxEvents` | integer, >= 0 | Retained events (`0` keeps none) | +| `history.eventDetail` | `'essential'` or `'verbose'` | Whether per-tick movement and energy events are emitted | +| `history.maxHistoricalOrganisms` | integer, >= 0 | Retained historical organism records (ring buffer) | +| `genetics.mutationRateScale` | >= 0 | Multiplies every gene's mutation rate; `0` disables mutation | +| `genetics.mutationStrengthScale` | >= 0 | Multiplies every gene's mutation standard deviation | +| `genetics.blendAlpha` | >= 0 | Blend-crossover extension past the parents' values | +| `genetics.brain.inputSize`, `.hiddenSize`, `.memorySize`, `.outputSize` | integer, > 0 | Fixed brain architecture shared by every organism | +| `genetics.brain.initialWeightStd` | > 0 | Standard deviation of founders' random weights | +| `genetics.brain.weightMutationRate`, `.weightMutationStd` | rate in [0, 1], std >= 0 | Per-weight mutation probability and size | +| `genetics.learning.*` | learning rate > 0; discount, trace decay, exploration, risk, novelty, persistence in [0, 1] | Founder baseline learning genes (carried until Phase 5) | +| `genetics.learningInheritance.mode` | `'baldwinian'`, `'partial'` or `'lamarckian'` | How learned weights are inherited (wired in Phase 5) | +| `genetics.learningInheritance.fraction` | [0, 1] | Share of learning inherited in `'partial'` mode | +| `genetics.genes.` | optional `{min, max, mutationRate, mutationStd}` | Per-gene overrides of the default ranges and rates; the ordered life-stage ranges must not overlap | + +Cross-field rules also include: `reproduction.offspringInvestment * (1 + overheadFraction) <= reproduction.minEnergy`, and `2 * reproduction.offspringInvestment <= organisms.maxEnergy`. The gene table with default ranges is in [docs/genetics/genome.md](../genetics/genome.md); `organisms.*` and `reproduction.*` above are only the founder baseline, since each organism reads its traits from its own genome. The defaults are provisional and tuned by the engine's scenario tests. Determinism tests pin their configuration explicitly, so changing a default never changes a golden hash. The configuration is not part of the state hash (see [determinism.md](determinism.md)). diff --git a/docs/simulation/determinism.md b/docs/simulation/determinism.md index b778486..8cc503d 100644 --- a/docs/simulation/determinism.md +++ b/docs/simulation/determinism.md @@ -128,7 +128,7 @@ The stream is versioned. It begins with the four ASCII bytes `EVOH`, then `write | 9 | extinction | present flag (u8); when present, `extinctionTick` (f64), otherwise `0` (f64) | | 10 | events | `eventsEmitted` (f64) | -#### Version 2 (current, Phase 3+) +#### Version 2 (Phase 3; superseded by version 3 in Phase 4) Sections 1-5 and 9-10 are unchanged from version 1. Sections 6 and 8 append new fields at the end (never inserted or reordered), per ADR 0005: @@ -137,6 +137,17 @@ Sections 1-5 and 9-10 are unchanged from version 1. Sections 6 and 8 append new | 6 | organisms | count (u32), then per organism in ascending id: `id` (u32), `x`, `y`, `vx`, `vy`, `heading`, `age`, `energy`, `birthTick`, `lastTurnMagnitude`, `reproductionCooldownRemaining` (all f64), then `parentIds` (`writeUint32(count)` then each id as u32) | | 8 | metrics | `organismsBorn`, `deathsByStarvation`, `deathsByAge`, `resourcesSpawned`, `resourcesConsumed`, `reproductionAttempts`, `reproductionSuccesses`, `reproductionFailures` (u32 each), `energyConsumed`, `energyWasted` (f64 each) | +#### 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: + +| Order | Section | Contents (in this order) | +| ----- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 4 | ids | `nextEntityId` (u32), then `nextGenomeId` (u32) | +| 6 | organisms | count (u32), then per organism in ascending id: every version 2 field, then the genome: `genomeId` (u32); `parentGenomeIds` as count (u32) then each id (u32); every scalar gene in the fixed `GENE_SPECS` order (f64 each; integers and booleans are written as their numeric value); the brain architecture `inputSize`, `hiddenSize`, `memorySize`, `outputSize` (u32 each); the weight count (u32); every weight (f64) | + +The `LineageStore` and `HistoryStore` are derived records of what happened, not simulation state, and are **not** hashed (they cannot influence any later tick). The `genetics` stream's four state words are already hashed in section 5 because it has been one of the named streams since version 1. + `WorldConfig` is **not** part of the hash; golden tests pin their config explicitly. Fields for later phases are appended as new sections or new fields with a version bump. ## 8. State identity versus the event log diff --git a/docs/simulation/update-order.md b/docs/simulation/update-order.md index 6e70da2..e581658 100644 --- a/docs/simulation/update-order.md +++ b/docs/simulation/update-order.md @@ -31,19 +31,19 @@ Initial population and resources are created once in `World.create` (setup, not ## Per-system reads and writes -| Stage | Reads | Writes (owns) | Emits | -| ------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| clock | - | clock (`tick`, `time`) | - | -| environment | config, `environment` stream, resource count | resource store (spawn) | `resource-spawned` | -| spatial | organism and resource stores (ascending id) | resource index and organism index (both rebuilt from scratch each tick) | - | -| perception | organisms, resource index, config | perception buffer (nearest resource per organism; ties by `(distance², id)`) | - | -| decision | perception buffer, organisms (heading), `world` stream, config | intent buffer (turn, thrust); placeholder controller until Phase 5 | - | -| movement | intent buffer, config, clock | organism position, velocity, heading (clamped to the world rectangle), organism `lastTurnMagnitude` | `organism-moved` (verbose only) | -| interaction | organisms, resource index, config | resource `remaining` and removal; organism `energy` (**gain** only) | `resource-consumed`, `energy-changed` (verbose) | -| metabolism | organisms (incl. `lastTurnMagnitude`), config, clock | organism `energy` (**cost** only), organism `age` | `energy-changed` (verbose) | -| reproduction | organisms, organism index, `reproduction` stream, config | organism store (creates children); organism `energy` (**cost** only, both parents); `reproductionCooldownRemaining` | `organism-born`, `reproduction-attempted` | -| death | organisms, config | organism store (removal); history store (append) | `organism-died` (`starvation` or `age`) | -| metrics | events emitted this tick, stores | metrics collector, `extinctionTick` | - | +| Stage | Reads | Writes (owns) | Emits | +| ------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| clock | - | clock (`tick`, `time`) | - | +| environment | config, `environment` stream, resource count | resource store (spawn) | `resource-spawned` | +| spatial | organism and resource stores (ascending id) | resource index and organism index (both rebuilt from scratch each tick) | - | +| perception | organisms (own `sensorRadius` gene), resource index, config | perception buffer (nearest resource per organism; ties by `(distance², id)`) | - | +| decision | perception buffer, organisms (heading, own `wanderJitter` gene), `world` stream, config | intent buffer (turn, thrust); placeholder controller until Phase 5 | - | +| movement | intent buffer, organism genes (`maxSpeed`, `maxTurnRate`), config, clock | organism position, velocity, heading (clamped to the world rectangle), organism `lastTurnMagnitude` | `organism-moved` (verbose only) | +| interaction | organisms (own `radius` gene), resource index, config | resource `remaining` and removal; organism `energy` (**gain** only) | `resource-consumed`, `energy-changed` (verbose) | +| metabolism | organisms (incl. `lastTurnMagnitude`, cost genes), config, clock | organism `energy` (**cost** only), organism `age` | `energy-changed` (verbose) | +| reproduction | organisms (genomes), organism index, `reproduction` and `genetics` streams, config | organism store (creates children); organism `energy` (**cost** only, both parents); `reproductionCooldownRemaining` | `organism-born`, `reproduction-attempted` | +| death | organisms (own `maxAge` gene), config | organism store (removal); history store (append); lineage store (mark death) | `organism-died` (`starvation` or `age`) | +| metrics | events emitted this tick, stores | metrics collector, `extinctionTick` | - | `organism.energy` has three writers, never overlapping in what they do: interaction **adds** (capped at `maxEnergy`; overflow reported as wasted), metabolism **subtracts**, reproduction **subtracts** (a fixed cost from each successful parent). The stage order fixes the sequence: gain, then metabolic cost, then reproductive cost, each tick. @@ -51,20 +51,24 @@ Initial population and resources are created once in `World.create` (setup, not 1. **Clock**: `tick += 1`, `time = tick * deltaTime`. 2. **Environment**: expected spawns per tick are `resources.spawnRate * deltaTime`; the fractional remainder is carried deterministically; spawning never exceeds `resources.maxCount`. Positions come from the `environment` stream. -3. **Spatial rebuild**: two uniform grids, one over resources (cell size = `organisms.sensorRadius`) and one over organisms (cell size = `reproduction.searchRadius`); entries inserted in ascending id order; queries return candidates sorted by `(distance², id)`. -4. **Perception**: nearest resource within `sensorRadius`, else none. -5. **Decision**: seek the perceived resource, otherwise wander (heading noise from the `world` stream). This is a labelled placeholder replaced by the brain in Phase 5. -6. **Movement**: speed bounded by `maxSpeed` (scaled by `juvenileSpeedScale` for juveniles); position clamped inside the world rectangle; the turn actually applied (after clamping to `maxTurnRate`) 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` and effective `maxEnergy` 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 + 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 >= reproduction.minEnergy`); 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 `searchRadius` - both produce a `reproduction-attempted` event with `succeeded: false`. On success, each parent pays `reproduction.energyCost` and its cooldown resets to `reproduction.cooldown`; the child starts at age 0, `energy = min(reproduction.offspringEnergy, 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` 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. +3. **Spatial rebuild**: two uniform grids, one over resources (cell size = the upper bound of the `sensorRadius` gene range) and one over organisms (cell size = the upper bound of the `searchRadius` gene range); entries inserted in ascending id order; queries return candidates sorted by `(distance², id)`. +4. **Perception**: nearest resource within the organism's own `sensorRadius` gene, else none. +5. **Decision**: seek the perceived resource, otherwise wander (heading noise from the `world` stream, scaled by the organism's `wanderJitter` gene). This is a labelled placeholder replaced by the brain in Phase 5. +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. +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). ## Life stages `juvenile` / `mature` / `senescent` is a pure function of `age` against `organisms.maturityAge` and `organisms.senescenceAge` (`maturityAge <= age < senescenceAge` is `mature`). It is never stored on the organism - every reader (Movement, Interaction, Metabolism, Reproduction, `snapshot()`) derives it fresh from the current age, the same way a resource's `energyValue` is read from config rather than cached per-resource. +## Genes + +Since Phase 4 an organism's traits are read from its own genome, not from global config: `organisms.*` and `reproduction.*` are only the **founder baseline**. Systems never read another organism's genes except a reproduction partner's eligibility fields. Capability-linked costs (body-size scaling of basal cost and energy capacity, sensor cost scaling with sensor radius, reproduction cost scaling with investment) are derived, so no gene is free; the full list, ranges and formulas are in [docs/genetics/genome.md](../genetics/genome.md). Genomes are immutable; the only mutable per-organism fields remain the ones listed in the table above. + ## Extinction With the default `extinctionPolicy: 'stop'`, extinction is reported, the clock and environment keep advancing, and no organism ever appears unless an explicit event creates one. Phase 3 makes reproduction real but does not implement the other three `ExtinctionPolicy` values; config validation continues to reject anything but `'stop'`.