diff --git a/docs/decisions/0006-genetics-and-inheritance.md b/docs/decisions/0006-genetics-and-inheritance.md index 9d3644e..b36f945 100644 --- a/docs/decisions/0006-genetics-and-inheritance.md +++ b/docs/decisions/0006-genetics-and-inheritance.md @@ -34,7 +34,7 @@ The roadmap (3.4) makes the life-stage ages genome-controlled; Phase 3 had to pl ### 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. +Each gene's effective range is its default range with any `genetics.genes` override applied; a default edge that excludes the founder baseline is **widened** to include it, so changing a baseline in `organisms.*` or `reproduction.*` never needs a matching range override (an edge set explicitly by an override is never moved, and config validation rejects a baseline outside it). 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 @@ -49,6 +49,8 @@ parent pays = offspringInvestment * (1 + reproduction.overheadFraction) child energy = min(investmentA + investmentB, child maxEnergy) ``` +Because genes vary independently, a parent could in principle hold a `minEnergy` below the cost it would pay. `ReproductionSystem` therefore requires `energy >= max(minEnergy, parent pays)` to attempt, so paying can never drive energy negative. + 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. @@ -62,7 +64,7 @@ This **replaces** the config fields `reproduction.energyCost` and `reproduction. - **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]`. +- **learning-parameter**: `learningRate` mutates multiplicatively (a Gaussian fraction of its current value, so it stays positive and scales sensibly; the engine has no deterministic `exp` to mutate 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. @@ -83,11 +85,11 @@ The two partners are symmetric, so "mother" is the parent with the lower organis - 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. +Each independent gene (or correlated group) consumes the same draws per birth whatever the configuration: the mutation gate, value and direction draws are always taken and merely not applied when the gate fails, so changing a rate or strength 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"). +`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 ordering is enforced by the operators, not by keeping ranges apart: a gene declared `orderedAfter` another (`senescenceAge` after `maturityAge`, `maxAge` after `senescenceAge`) has its lower bound raised above the earlier gene (by a 1e-6 margin) in mutation, recombination and repair, so the ages stay ordered for any ranges, including after an unrelated change moves the earlier gene. Config validation requires each such gene's range maximum to exceed the earlier gene's, so there is always room. `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 diff --git a/docs/genetics/genome.md b/docs/genetics/genome.md index 0176527..2bf4f76 100644 --- a/docs/genetics/genome.md +++ b/docs/genetics/genome.md @@ -8,33 +8,35 @@ All defaults are provisional. They are tuned by the engine's scenario tests, and 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 | 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 (relative) | 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 ordering `maturityAge < senescenceAge < maxAge` is enforced by the operators: `senescenceAge` is declared `orderedAfter` `maturityAge`, and `maxAge` after `senescenceAge`, and mutation, recombination and repair raise such a gene's lower bound above the earlier gene (by a 1e-6 margin) even when the later gene itself did not mutate. Config validation requires each ordered gene's range maximum to exceed the earlier gene's, so there is always room. The default ranges happen not to overlap, but nothing relies on that. + +If a default range edge excludes a changed founder baseline (for example `organisms.maxSpeed: 0`), that edge is widened to include it. An edge set explicitly in `genetics.genes` is never moved; config validation rejects a baseline outside it. For a relative gene (`learningRate`) the standard deviation is a fraction of the current value rather than of the range width. Gene order in `GENE_SPECS` is fixed (the order above) and is the order used for mutation draws, the state hash and `genomeDistance`. @@ -49,6 +51,7 @@ 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) +eligibility = energy >= max(minEnergy, parent pays) (so paying never drives energy negative) ``` 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. @@ -82,7 +85,7 @@ Default total: 249 weights. Crossover operates per block (see below). No Phase 4 | 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 | +| `learning.learningRate` | relative gaussian: add `N(0, std) * current value`, 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. diff --git a/docs/simulation/configuration.md b/docs/simulation/configuration.md index 33f2265..50b0b70 100644 --- a/docs/simulation/configuration.md +++ b/docs/simulation/configuration.md @@ -22,54 +22,54 @@ 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 (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 | +| 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. A default edge that excludes the founder baseline is widened to include it; an explicit edge must contain the baseline; each life-stage gene's range max must exceed the previous one's | 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. diff --git a/packages/config/src/checks.ts b/packages/config/src/checks.ts new file mode 100644 index 0000000..05bd08c --- /dev/null +++ b/packages/config/src/checks.ts @@ -0,0 +1,115 @@ +export type PlainObject = Record + +export const isObject = (value: unknown): value is PlainObject => + typeof value === 'object' && value !== null && !Array.isArray(value) + +export interface NumberRule { + integer?: boolean + /** Value must be strictly greater than zero. */ + positive?: boolean + /** Value must be zero or greater. */ + nonnegative?: boolean +} + +export function checkNumber( + section: PlainObject, + key: string, + path: string, + rule: NumberRule, + errors: string[] +): number | undefined { + const value = section[key] + if (value === undefined) { + errors.push(`${path} is required`) + return undefined + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + errors.push(`${path} must be a finite number`) + return undefined + } + if (rule.integer && !Number.isInteger(value)) { + errors.push(`${path} must be an integer`) + return undefined + } + if (rule.positive && value <= 0) { + errors.push( + rule.integer + ? `${path} must be an integer greater than zero` + : `${path} must be greater than zero` + ) + return undefined + } + if (rule.nonnegative && value < 0) { + errors.push(`${path} must be nonnegative`) + return undefined + } + return value +} + +/** A multiplier in `(0, 1]` (juvenile scaling factors). */ +export function checkFraction( + section: PlainObject, + key: string, + path: string, + errors: string[] +): number | undefined { + const value = checkNumber(section, key, path, {}, errors) + if (value === undefined) return undefined + if (value <= 0 || value > 1) { + errors.push(`${path} must be greater than zero and at most one`) + return undefined + } + return value +} + +export function checkKeys( + section: PlainObject, + allowed: string[], + path: string, + errors: string[] +): void { + for (const key of Object.keys(section)) { + if (!allowed.includes(key)) { + errors.push(`unknown property "${path === '' ? key : `${path}.${key}`}"`) + } + } +} + +export function checkSection( + root: PlainObject, + key: string, + allowed: string[], + errors: string[], + path: string = key +): PlainObject | undefined { + const section = root[key] + if (section === undefined) { + errors.push(`${path} is required`) + return undefined + } + if (!isObject(section)) { + errors.push(`${path} must be an object`) + return undefined + } + checkKeys(section, allowed, path, errors) + return section +} + +export function checkChoice( + section: PlainObject, + key: string, + path: string, + choices: string[], + errors: string[] +): string | undefined { + const value = section[key] + if (value === undefined) { + errors.push(`${path} is required`) + return undefined + } + if (typeof value !== 'string' || !choices.includes(value)) { + errors.push(`${path} must be one of: ${choices.join(', ')}`) + return undefined + } + return value +} diff --git a/packages/config/src/defaults.ts b/packages/config/src/defaults.ts index ae650de..7f1b921 100644 --- a/packages/config/src/defaults.ts +++ b/packages/config/src/defaults.ts @@ -1,4 +1,4 @@ -import type { WorldConfig } from '@evo/contracts' +import type { GenePath, GeneRange, WorldConfig } from '@evo/contracts' /** Recursively freezes plain objects so shared defaults cannot be mutated by accident. */ function deepFreeze(value: T): T { @@ -34,6 +34,7 @@ export const DEFAULT_WORLD_CONFIG: Readonly = deepFreeze({ movementCost: 0.005, turningCost: 0.05, sensorCost: 0.1, + hue: 0.55, maxAge: 600, maturityAge: 90, senescenceAge: 510, @@ -50,10 +51,74 @@ export const DEFAULT_WORLD_CONFIG: Readonly = deepFreeze({ }, reproduction: { minEnergy: 60, - energyCost: 20, - offspringEnergy: 25, + offspringInvestment: 12.5, + overheadFraction: 0.6, cooldown: 30, searchRadius: 50 }, - history: { maxEvents: 10_000, eventDetail: 'essential', maxHistoricalOrganisms: 10_000 } + history: { maxEvents: 10_000, eventDetail: 'essential', maxHistoricalOrganisms: 10_000 }, + genetics: { + mutationRateScale: 1, + mutationStrengthScale: 1, + 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: {} + } } satisfies WorldConfig) + +const range = (min: number, max: number, mutationStd = 0.05): GeneRange => ({ + min, + max, + mutationRate: 0.1, + mutationStd +}) + +/** + * Default bounds and mutation parameters for every gene (docs/genetics/genome.md). The ranges of + * `maturityAge`, `senescenceAge` and `maxAge` are disjoint and ordered so that + * `maturityAge < senescenceAge < maxAge` holds for every genome by construction. Like the world + * defaults these are provisional and tuned by the engine's scenario tests. + */ +export const DEFAULT_GENE_RANGES: Readonly>> = deepFreeze({ + 'physical.radius': range(2.5, 10), + 'physical.maxSpeed': range(10, 40), + 'physical.maxTurnRate': range(1.5, 6), + 'physical.maturityAge': range(45, 180), + 'physical.senescenceAge': range(255, 560), + 'physical.maxAge': range(570, 1200), + 'physical.hue': range(0, 1), + 'sensory.sensorRadius': range(30, 120), + 'metabolic.basalCost': range(0.3, 0.8), + 'metabolic.movementCost': range(0.003, 0.008), + 'metabolic.turningCost': range(0.03, 0.08), + 'behavioral.wanderJitter': range(0.75, 3), + 'reproduction.minEnergy': range(40, 90), + 'reproduction.offspringInvestment': range(6, 25), + 'reproduction.cooldown': range(15, 60), + 'reproduction.searchRadius': range(25, 100), + 'learning.learningRate': range(0.001, 0.5, 0.1), + 'learning.discountFactor': range(0, 1), + 'learning.eligibilityTraceDecay': range(0, 1), + 'learning.explorationRate': range(0, 1), + 'learning.riskTolerance': range(0, 1), + 'learning.noveltyPreference': range(0, 1), + 'learning.persistence': range(0, 1) +}) diff --git a/packages/config/src/genetics.ts b/packages/config/src/genetics.ts new file mode 100644 index 0000000..bf97003 --- /dev/null +++ b/packages/config/src/genetics.ts @@ -0,0 +1,312 @@ +import { GENE_DEFINITIONS } from '@evo/contracts' +import type { GenePath, GeneRange, GeneRangeOverrides, WorldConfig } from '@evo/contracts' +import { checkChoice, checkKeys, checkNumber, checkSection, isObject } from './checks' +import type { PlainObject } from './checks' +import { DEFAULT_GENE_RANGES } from './defaults' + +const GENETICS_KEYS = [ + 'mutationRateScale', + 'mutationStrengthScale', + 'blendAlpha', + 'brain', + 'learning', + 'learningInheritance', + 'genes' +] +const BRAIN_KEYS = [ + 'inputSize', + 'hiddenSize', + 'memorySize', + 'outputSize', + 'initialWeightStd', + 'weightMutationRate', + 'weightMutationStd' +] +const LEARNING_KEYS = [ + 'learningRate', + 'discountFactor', + 'eligibilityTraceDecay', + 'explorationRate', + 'riskTolerance', + 'noveltyPreference', + 'persistence' +] +const INHERITANCE_KEYS = ['mode', 'fraction'] +const INHERITANCE_MODES = ['baldwinian', 'partial', 'lamarckian'] +const RANGE_KEYS = ['min', 'max', 'mutationRate', 'mutationStd'] +const GENE_PATHS: readonly string[] = GENE_DEFINITIONS.map((definition) => definition.path) + +/** + * The effective range of every gene: the default range, with per-gene overrides applied field by + * field. A default edge that excludes the gene's founder baseline is widened to include it, so + * changing a baseline in `organisms.*` / `reproduction.*` never needs a matching range override. + * An edge set explicitly by an override is never moved (validation rejects a baseline outside it). + */ +export function resolveGeneRanges( + overrides: GeneRangeOverrides, + baselines: Partial> = {} +): Record { + const resolved = {} as Record + for (const definition of GENE_DEFINITIONS) { + const raw: unknown = overrides[definition.path] + const override: Partial = isObject(raw) ? (raw as Partial) : {} + const range = { ...DEFAULT_GENE_RANGES[definition.path], ...override } + const baseline = baselines[definition.path] + if (baseline !== undefined) { + if (override.min === undefined && baseline < range.min) range.min = baseline + if (override.max === undefined && baseline > range.max) range.max = baseline + } + resolved[definition.path] = range + } + return resolved +} + +/** Each gene's founder value: the config field it was previously read from. */ +export function geneBaselines(config: WorldConfig): Record { + const { organisms, reproduction, genetics } = config + return { + 'physical.radius': organisms.radius, + 'physical.maxSpeed': organisms.maxSpeed, + 'physical.maxTurnRate': organisms.maxTurnRate, + 'physical.maturityAge': organisms.maturityAge, + 'physical.senescenceAge': organisms.senescenceAge, + 'physical.maxAge': organisms.maxAge, + 'physical.hue': organisms.hue, + 'sensory.sensorRadius': organisms.sensorRadius, + 'metabolic.basalCost': organisms.basalCost, + 'metabolic.movementCost': organisms.movementCost, + 'metabolic.turningCost': organisms.turningCost, + 'behavioral.wanderJitter': organisms.wanderJitter, + 'reproduction.minEnergy': reproduction.minEnergy, + 'reproduction.offspringInvestment': reproduction.offspringInvestment, + 'reproduction.cooldown': reproduction.cooldown, + 'reproduction.searchRadius': reproduction.searchRadius, + 'learning.learningRate': genetics.learning.learningRate, + 'learning.discountFactor': genetics.learning.discountFactor, + 'learning.eligibilityTraceDecay': genetics.learning.eligibilityTraceDecay, + 'learning.explorationRate': genetics.learning.explorationRate, + 'learning.riskTolerance': genetics.learning.riskTolerance, + 'learning.noveltyPreference': genetics.learning.noveltyPreference, + 'learning.persistence': genetics.learning.persistence + } +} + +/** A number in the closed interval `[0, 1]`. */ +function checkUnit( + section: PlainObject, + key: string, + path: string, + errors: string[] +): number | undefined { + const value = checkNumber(section, key, path, {}, errors) + if (value === undefined) return undefined + if (value < 0 || value > 1) { + errors.push(`${path} must be between zero and one`) + return undefined + } + return value +} + +interface GeneticsContext { + /** The merged value being validated; used to derive baselines and cross-field rules. */ + root: PlainObject +} + +function numberAt(section: unknown, key: string): number | undefined { + if (!isObject(section)) return undefined + const value = section[key] + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function checkGeneOverrides(genes: unknown, errors: string[]): void { + if (!isObject(genes)) { + errors.push('genetics.genes must be an object') + return + } + for (const [path, override] of Object.entries(genes)) { + if (!GENE_PATHS.includes(path)) { + errors.push(`unknown property "genetics.genes.${path}"`) + continue + } + if (!isObject(override)) { + errors.push(`genetics.genes.${path} must be an object`) + continue + } + checkKeys(override, RANGE_KEYS, `genetics.genes.${path}`, errors) + for (const key of RANGE_KEYS) { + if (override[key] === undefined) continue + const value = override[key] + if (typeof value !== 'number' || !Number.isFinite(value)) { + errors.push(`genetics.genes.${path}.${key} must be a finite number`) + } + } + } +} + +/** Per-gene range rules, evaluated on the effective (default + override) ranges. */ +function checkEffectiveRanges(context: GeneticsContext, errors: string[]): void { + const { root } = context + const genetics = root['genetics'] + if (!isObject(genetics) || !isObject(genetics['genes'])) return + const genes = genetics['genes'] as GeneRangeOverrides + const baselines = baselineValues(root) + const ranges = resolveGeneRanges(genes, baselines) + + for (const definition of GENE_DEFINITIONS) { + const range = ranges[definition.path] + const label = `genetics.genes.${definition.path}` + if ( + !Number.isFinite(range.min) || + !Number.isFinite(range.max) || + !Number.isFinite(range.mutationRate) || + !Number.isFinite(range.mutationStd) + ) { + continue + } + if (range.min >= range.max) { + errors.push(`${label}: min must be less than max`) + continue + } + if (range.mutationRate < 0 || range.mutationRate > 1) { + errors.push(`${label}: mutationRate must be between zero and one`) + } + if (range.mutationStd < 0) { + errors.push(`${label}: mutationStd must be nonnegative`) + } + const baseline = baselines[definition.path] + if (baseline !== undefined) { + const inside = + definition.kind === 'circular' + ? baseline >= range.min && baseline < range.max + : baseline >= range.min && baseline <= range.max + if (!inside) { + errors.push(`${label}: the founder baseline (${baseline}) must lie within [min, max]`) + } + } + } + + // Ordered genes are kept in order by the operators (lower bound raised above the earlier + // gene), which needs room above it: the upper edges must increase strictly. + for (const definition of GENE_DEFINITIONS) { + if (definition.orderedAfter === undefined) continue + if (ranges[definition.path].max <= ranges[definition.orderedAfter].max) { + errors.push( + `the ${definition.path} gene range max must be greater than the ${definition.orderedAfter} gene range max` + ) + } + } + if (ranges['reproduction.offspringInvestment'].min <= 0) { + errors.push('genetics.genes.reproduction.offspringInvestment: min must be greater than zero') + } +} + +function baselineValues(root: PlainObject): Partial> { + const organisms = root['organisms'] + const reproduction = root['reproduction'] + const learning = isObject(root['genetics']) ? root['genetics']['learning'] : undefined + const values: Partial> = {} + const set = (path: GenePath, value: number | undefined): void => { + if (value !== undefined) values[path] = value + } + set('physical.radius', numberAt(organisms, 'radius')) + set('physical.maxSpeed', numberAt(organisms, 'maxSpeed')) + set('physical.maxTurnRate', numberAt(organisms, 'maxTurnRate')) + set('physical.maturityAge', numberAt(organisms, 'maturityAge')) + set('physical.senescenceAge', numberAt(organisms, 'senescenceAge')) + set('physical.maxAge', numberAt(organisms, 'maxAge')) + set('physical.hue', numberAt(organisms, 'hue')) + set('sensory.sensorRadius', numberAt(organisms, 'sensorRadius')) + set('metabolic.basalCost', numberAt(organisms, 'basalCost')) + set('metabolic.movementCost', numberAt(organisms, 'movementCost')) + set('metabolic.turningCost', numberAt(organisms, 'turningCost')) + set('behavioral.wanderJitter', numberAt(organisms, 'wanderJitter')) + set('reproduction.minEnergy', numberAt(reproduction, 'minEnergy')) + set('reproduction.offspringInvestment', numberAt(reproduction, 'offspringInvestment')) + set('reproduction.cooldown', numberAt(reproduction, 'cooldown')) + set('reproduction.searchRadius', numberAt(reproduction, 'searchRadius')) + set('learning.learningRate', numberAt(learning, 'learningRate')) + set('learning.discountFactor', numberAt(learning, 'discountFactor')) + set('learning.eligibilityTraceDecay', numberAt(learning, 'eligibilityTraceDecay')) + set('learning.explorationRate', numberAt(learning, 'explorationRate')) + set('learning.riskTolerance', numberAt(learning, 'riskTolerance')) + set('learning.noveltyPreference', numberAt(learning, 'noveltyPreference')) + set('learning.persistence', numberAt(learning, 'persistence')) + return values +} + +/** Validates the `genetics` section of a complete configuration (`root`). */ +export function checkGenetics(root: PlainObject, errors: string[]): void { + const genetics = checkSection(root, 'genetics', GENETICS_KEYS, errors) + if (!genetics) return + + checkNumber( + genetics, + 'mutationRateScale', + 'genetics.mutationRateScale', + { nonnegative: true }, + errors + ) + checkNumber( + genetics, + 'mutationStrengthScale', + 'genetics.mutationStrengthScale', + { nonnegative: true }, + errors + ) + checkNumber(genetics, 'blendAlpha', 'genetics.blendAlpha', { nonnegative: true }, errors) + + const brain = checkSection(genetics, 'brain', BRAIN_KEYS, errors, 'genetics.brain') + if (brain) { + for (const key of ['inputSize', 'hiddenSize', 'memorySize', 'outputSize']) { + checkNumber(brain, key, `genetics.brain.${key}`, { integer: true, positive: true }, errors) + } + checkNumber( + brain, + 'initialWeightStd', + 'genetics.brain.initialWeightStd', + { positive: true }, + errors + ) + checkUnit(brain, 'weightMutationRate', 'genetics.brain.weightMutationRate', errors) + checkNumber( + brain, + 'weightMutationStd', + 'genetics.brain.weightMutationStd', + { nonnegative: true }, + errors + ) + } + + const learning = checkSection(genetics, 'learning', LEARNING_KEYS, errors, 'genetics.learning') + if (learning) { + checkNumber( + learning, + 'learningRate', + 'genetics.learning.learningRate', + { positive: true }, + errors + ) + for (const key of LEARNING_KEYS.filter((name) => name !== 'learningRate')) { + checkUnit(learning, key, `genetics.learning.${key}`, errors) + } + } + + const inheritance = checkSection( + genetics, + 'learningInheritance', + INHERITANCE_KEYS, + errors, + 'genetics.learningInheritance' + ) + if (inheritance) { + checkChoice(inheritance, 'mode', 'genetics.learningInheritance.mode', INHERITANCE_MODES, errors) + checkUnit(inheritance, 'fraction', 'genetics.learningInheritance.fraction', errors) + } + + if (genetics['genes'] === undefined) { + errors.push('genetics.genes is required') + } else { + checkGeneOverrides(genetics['genes'], errors) + checkEffectiveRanges({ root }, errors) + } +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 984c5ca..1d84e30 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,4 +1,5 @@ -export { DEFAULT_WORLD_CONFIG } from './defaults' +export { DEFAULT_GENE_RANGES, DEFAULT_WORLD_CONFIG } from './defaults' +export { geneBaselines, resolveGeneRanges } from './genetics' export { ConfigValidationError, resolveWorldConfig, diff --git a/packages/config/src/validation.ts b/packages/config/src/validation.ts index ce1f25a..ffd90b2 100644 --- a/packages/config/src/validation.ts +++ b/packages/config/src/validation.ts @@ -1,11 +1,16 @@ import { MAX_SEED } from '@evo/contracts' import type { ValidationResult, WorldConfig, WorldConfigOverrides } from '@evo/contracts' import { DEFAULT_WORLD_CONFIG } from './defaults' - -type PlainObject = Record - -const isObject = (value: unknown): value is PlainObject => - typeof value === 'object' && value !== null && !Array.isArray(value) +import { + checkChoice, + checkFraction, + checkKeys, + checkNumber, + checkSection, + isObject +} from './checks' +import type { PlainObject } from './checks' +import { checkGenetics } from './genetics' const ROOT_KEYS = [ 'timestep', @@ -15,7 +20,8 @@ const ROOT_KEYS = [ 'organisms', 'resources', 'reproduction', - 'history' + 'history', + 'genetics' ] const ENVIRONMENT_KEYS = ['width', 'height'] const ORGANISM_KEYS = [ @@ -31,6 +37,7 @@ const ORGANISM_KEYS = [ 'movementCost', 'turningCost', 'sensorCost', + 'hue', 'maxAge', 'maturityAge', 'senescenceAge', @@ -39,117 +46,18 @@ const ORGANISM_KEYS = [ 'juvenileMetabolicScale' ] const RESOURCE_KEYS = ['initialCount', 'maxCount', 'spawnRate', 'energyValue', 'radius'] -const REPRODUCTION_KEYS = ['minEnergy', 'energyCost', 'offspringEnergy', 'cooldown', 'searchRadius'] +const REPRODUCTION_KEYS = [ + 'minEnergy', + 'offspringInvestment', + 'overheadFraction', + 'cooldown', + 'searchRadius' +] const HISTORY_KEYS = ['maxEvents', 'eventDetail', 'maxHistoricalOrganisms'] const EXTINCTION_POLICIES = ['stop', 'allow-immigration', 'seed-bank', 'sandbox-recovery'] const EVENT_DETAILS = ['essential', 'verbose'] -interface NumberRule { - integer?: boolean - /** Value must be strictly greater than zero. */ - positive?: boolean - /** Value must be zero or greater. */ - nonnegative?: boolean -} - -function checkNumber( - section: PlainObject, - key: string, - path: string, - rule: NumberRule, - errors: string[] -): number | undefined { - const value = section[key] - if (value === undefined) { - errors.push(`${path} is required`) - return undefined - } - if (typeof value !== 'number' || !Number.isFinite(value)) { - errors.push(`${path} must be a finite number`) - return undefined - } - if (rule.integer && !Number.isInteger(value)) { - errors.push(`${path} must be an integer`) - return undefined - } - if (rule.positive && value <= 0) { - errors.push( - rule.integer - ? `${path} must be an integer greater than zero` - : `${path} must be greater than zero` - ) - return undefined - } - if (rule.nonnegative && value < 0) { - errors.push(`${path} must be nonnegative`) - return undefined - } - return value -} - -/** A multiplier in `(0, 1]` (juvenile scaling factors). */ -function checkFraction( - section: PlainObject, - key: string, - path: string, - errors: string[] -): number | undefined { - const value = checkNumber(section, key, path, {}, errors) - if (value === undefined) return undefined - if (value <= 0 || value > 1) { - errors.push(`${path} must be greater than zero and at most one`) - return undefined - } - return value -} - -function checkKeys(section: PlainObject, allowed: string[], path: string, errors: string[]): void { - for (const key of Object.keys(section)) { - if (!allowed.includes(key)) { - errors.push(`unknown property "${path === '' ? key : `${path}.${key}`}"`) - } - } -} - -function checkSection( - root: PlainObject, - key: string, - allowed: string[], - errors: string[] -): PlainObject | undefined { - const section = root[key] - if (section === undefined) { - errors.push(`${key} is required`) - return undefined - } - if (!isObject(section)) { - errors.push(`${key} must be an object`) - return undefined - } - checkKeys(section, allowed, key, errors) - return section -} - -function checkChoice( - section: PlainObject, - key: string, - path: string, - choices: string[], - errors: string[] -): string | undefined { - const value = section[key] - if (value === undefined) { - errors.push(`${path} is required`) - return undefined - } - if (typeof value !== 'string' || !choices.includes(value)) { - errors.push(`${path} must be one of: ${choices.join(', ')}`) - return undefined - } - return value -} - /** * Validates a complete world configuration of unknown origin (for example parsed JSON). * Reports every problem found; never repairs or normalizes values. @@ -241,6 +149,10 @@ export function validateWorldConfig(value: unknown): ValidationResult { checkFraction(organisms, 'juvenileSizeScale', 'organisms.juvenileSizeScale', errors) checkFraction(organisms, 'juvenileSpeedScale', 'organisms.juvenileSpeedScale', errors) checkFraction(organisms, 'juvenileMetabolicScale', 'organisms.juvenileMetabolicScale', errors) + const hue = checkNumber(organisms, 'hue', 'organisms.hue', {}, errors) + if (hue !== undefined && (hue < 0 || hue >= 1)) { + errors.push('organisms.hue must be at least zero and less than one') + } if (initialCount !== undefined && maxPopulation !== undefined && initialCount > maxPopulation) { errors.push('organisms.initialCount must not exceed maxPopulation') @@ -300,18 +212,18 @@ export function validateWorldConfig(value: unknown): ValidationResult { { positive: true }, errors ) - const energyCost = checkNumber( + const investment = checkNumber( reproduction, - 'energyCost', - 'reproduction.energyCost', + 'offspringInvestment', + 'reproduction.offspringInvestment', { positive: true }, errors ) - const offspringEnergy = checkNumber( + const overhead = checkNumber( reproduction, - 'offspringEnergy', - 'reproduction.offspringEnergy', - { positive: true }, + 'overheadFraction', + 'reproduction.overheadFraction', + { nonnegative: true }, errors ) checkNumber(reproduction, 'cooldown', 'reproduction.cooldown', { nonnegative: true }, errors) @@ -323,15 +235,22 @@ export function validateWorldConfig(value: unknown): ValidationResult { errors ) - if (energyCost !== undefined && minEnergy !== undefined && energyCost > minEnergy) { - errors.push('reproduction.energyCost must not exceed reproduction.minEnergy') + if ( + investment !== undefined && + overhead !== undefined && + minEnergy !== undefined && + investment * (1 + overhead) > minEnergy + ) { + errors.push( + 'reproduction.offspringInvestment * (1 + reproduction.overheadFraction) must not exceed reproduction.minEnergy' + ) } if ( - offspringEnergy !== undefined && + investment !== undefined && organismsMaxEnergy !== undefined && - offspringEnergy > organismsMaxEnergy + investment * 2 > organismsMaxEnergy ) { - errors.push('reproduction.offspringEnergy must not exceed organisms.maxEnergy') + errors.push('twice reproduction.offspringInvestment must not exceed organisms.maxEnergy') } } @@ -354,6 +273,8 @@ export function validateWorldConfig(value: unknown): ValidationResult { ) } + checkGenetics(value, errors) + return { ok: errors.length === 0, errors } } @@ -376,6 +297,23 @@ export class ConfigValidationError extends Error { } } +/** Merges the genetics section: its nested sections merge field by field, `genes` by gene path. */ +function mergeGenetics(base: object, override: unknown): unknown { + if (override === undefined || !isObject(override)) return mergeSection(base, override) + const defaults = base as Record + return { + ...base, + ...override, + brain: mergeSection(defaults['brain'] as object, override['brain']), + learning: mergeSection(defaults['learning'] as object, override['learning']), + learningInheritance: mergeSection( + defaults['learningInheritance'] as object, + override['learningInheritance'] + ), + genes: mergeSection(defaults['genes'] as object, override['genes']) + } +} + /** Merges one section, keeping non-object overrides so validation can report them. */ function mergeSection(base: object, override: unknown): unknown { if (override === undefined) return { ...base } @@ -399,7 +337,8 @@ export function resolveWorldConfig(overrides?: WorldConfigOverrides): WorldConfi organisms: mergeSection(base.organisms, given['organisms']), resources: mergeSection(base.resources, given['resources']), reproduction: mergeSection(base.reproduction, given['reproduction']), - history: mergeSection(base.history, given['history']) + history: mergeSection(base.history, given['history']), + genetics: mergeGenetics(base.genetics, given['genetics']) } const result = validateWorldConfig(merged) if (!result.ok) throw new ConfigValidationError(result.errors) diff --git a/packages/config/tests/genetics.test.ts b/packages/config/tests/genetics.test.ts new file mode 100644 index 0000000..97c7924 --- /dev/null +++ b/packages/config/tests/genetics.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from 'vitest' +import { GENE_DEFINITIONS } from '@evo/contracts' +import type { WorldConfig } from '@evo/contracts' +import { + DEFAULT_GENE_RANGES, + DEFAULT_WORLD_CONFIG, + geneBaselines, + resolveGeneRanges, + resolveWorldConfig, + validateWorldConfig +} from '../src/index' + +function clone(): WorldConfig { + return JSON.parse(JSON.stringify(DEFAULT_WORLD_CONFIG)) as WorldConfig +} + +function errorsFor(mutate: (config: WorldConfig) => void): readonly string[] { + const config = clone() + mutate(config) + return validateWorldConfig(config).errors +} + +/** The loosely-typed view tests need to inject malformed values. */ +function loose(config: WorldConfig): Record> { + return config as unknown as Record> +} + +describe('gene ranges', () => { + it('has a default range for every gene, in the canonical order', () => { + for (const definition of GENE_DEFINITIONS) { + expect(DEFAULT_GENE_RANGES[definition.path]).toBeDefined() + } + expect(Object.keys(resolveGeneRanges({}))).toEqual(GENE_DEFINITIONS.map((d) => d.path)) + }) + + it('every founder baseline lies inside its default range', () => { + const baselines = geneBaselines(DEFAULT_WORLD_CONFIG) + for (const definition of GENE_DEFINITIONS) { + const range = DEFAULT_GENE_RANGES[definition.path] + const value = baselines[definition.path] + expect(value).toBeGreaterThanOrEqual(range.min) + expect(value).toBeLessThanOrEqual(range.max) + } + }) + + it('maps each gene to the config field it replaces', () => { + const config = resolveWorldConfig({ + organisms: { radius: 4, maxSpeed: 11, hue: 0.2, sensorRadius: 70 }, + reproduction: { minEnergy: 70, cooldown: 20 }, + genetics: { learning: { learningRate: 0.2 } } + }) + const baselines = geneBaselines(config) + expect(baselines['physical.radius']).toBe(4) + expect(baselines['physical.maxSpeed']).toBe(11) + expect(baselines['physical.hue']).toBe(0.2) + expect(baselines['sensory.sensorRadius']).toBe(70) + expect(baselines['reproduction.minEnergy']).toBe(70) + expect(baselines['reproduction.cooldown']).toBe(20) + expect(baselines['learning.learningRate']).toBe(0.2) + }) + + it('merges per-gene overrides field by field onto the defaults', () => { + const ranges = resolveGeneRanges({ 'physical.maxSpeed': { max: 50, mutationRate: 0.5 } }) + expect(ranges['physical.maxSpeed']).toEqual({ + ...DEFAULT_GENE_RANGES['physical.maxSpeed'], + max: 50, + mutationRate: 0.5 + }) + expect(ranges['physical.radius']).toEqual(DEFAULT_GENE_RANGES['physical.radius']) + }) + + it('widens a default edge to include a changed founder baseline', () => { + const baselines = { 'physical.maxSpeed': 0, 'physical.radius': 50 } + const ranges = resolveGeneRanges({}, baselines) + expect(ranges['physical.maxSpeed'].min).toBe(0) + expect(ranges['physical.maxSpeed'].max).toBe(DEFAULT_GENE_RANGES['physical.maxSpeed'].max) + expect(ranges['physical.radius'].max).toBe(50) + expect(ranges['physical.radius'].min).toBe(DEFAULT_GENE_RANGES['physical.radius'].min) + }) + + it('never moves an edge set explicitly by an override', () => { + const ranges = resolveGeneRanges( + { 'physical.maxSpeed': { min: 25 } }, + { 'physical.maxSpeed': 20 } + ) + expect(ranges['physical.maxSpeed'].min).toBe(25) + }) + + it('declares the life-stage ages as ordered genes', () => { + const ordered = GENE_DEFINITIONS.filter((d) => d.orderedAfter !== undefined).map((d) => [ + d.path, + d.orderedAfter + ]) + expect(ordered).toEqual([ + ['physical.senescenceAge', 'physical.maturityAge'], + ['physical.maxAge', 'physical.senescenceAge'] + ]) + }) +}) + +describe('genetics validation', () => { + it('accepts the defaults', () => { + expect(validateWorldConfig(DEFAULT_WORLD_CONFIG)).toEqual({ ok: true, errors: [] }) + }) + + it('validates the global mutation and blend knobs', () => { + expect(errorsFor((c) => (c.genetics.mutationRateScale = -1))).toContain( + 'genetics.mutationRateScale must be nonnegative' + ) + expect(errorsFor((c) => (c.genetics.mutationStrengthScale = -1))).toContain( + 'genetics.mutationStrengthScale must be nonnegative' + ) + expect(errorsFor((c) => (c.genetics.blendAlpha = -0.1))).toContain( + 'genetics.blendAlpha must be nonnegative' + ) + expect( + errorsFor((c) => { + c.genetics.mutationRateScale = 0 + c.genetics.mutationStrengthScale = 0 + }) + ).toEqual([]) + }) + + it('validates the brain section', () => { + for (const key of ['inputSize', 'hiddenSize', 'memorySize', 'outputSize'] as const) { + expect(errorsFor((c) => (c.genetics.brain[key] = 0))).toContain( + `genetics.brain.${key} must be an integer greater than zero` + ) + expect(errorsFor((c) => (c.genetics.brain[key] = 2.5))).toContain( + `genetics.brain.${key} must be an integer` + ) + } + expect(errorsFor((c) => (c.genetics.brain.initialWeightStd = 0))).toContain( + 'genetics.brain.initialWeightStd must be greater than zero' + ) + expect(errorsFor((c) => (c.genetics.brain.weightMutationRate = 1.5))).toContain( + 'genetics.brain.weightMutationRate must be between zero and one' + ) + expect(errorsFor((c) => (c.genetics.brain.weightMutationStd = -1))).toContain( + 'genetics.brain.weightMutationStd must be nonnegative' + ) + }) + + it('validates the founder learning baseline', () => { + expect(errorsFor((c) => (c.genetics.learning.learningRate = 0))).toContain( + 'genetics.learning.learningRate must be greater than zero' + ) + for (const key of [ + 'discountFactor', + 'eligibilityTraceDecay', + 'explorationRate', + 'riskTolerance', + 'noveltyPreference', + 'persistence' + ] as const) { + expect(errorsFor((c) => (c.genetics.learning[key] = 1.1))).toContain( + `genetics.learning.${key} must be between zero and one` + ) + expect(errorsFor((c) => (c.genetics.learning[key] = -0.1))).toContain( + `genetics.learning.${key} must be between zero and one` + ) + } + }) + + it('validates the learning inheritance mode and fraction', () => { + expect( + errorsFor((c) => ((c.genetics.learningInheritance as { mode: string }).mode = 'magic')) + ).toContain('genetics.learningInheritance.mode must be one of: baldwinian, partial, lamarckian') + expect(errorsFor((c) => (c.genetics.learningInheritance.fraction = 1.5))).toContain( + 'genetics.learningInheritance.fraction must be between zero and one' + ) + for (const mode of ['baldwinian', 'partial', 'lamarckian'] as const) { + expect(errorsFor((c) => (c.genetics.learningInheritance.mode = mode))).toEqual([]) + } + }) + + it('rejects unknown genes and unknown range fields', () => { + expect(errorsFor((c) => (loose(c)['genetics']!['genes'] = { 'physical.wings': {} }))).toContain( + 'unknown property "genetics.genes.physical.wings"' + ) + expect( + errorsFor((c) => (loose(c)['genetics']!['genes'] = { 'physical.radius': { average: 1 } })) + ).toContain('unknown property "genetics.genes.physical.radius.average"') + expect(errorsFor((c) => (loose(c)['genetics']!['genes'] = { 'physical.radius': 5 }))).toContain( + 'genetics.genes.physical.radius must be an object' + ) + expect(errorsFor((c) => (loose(c)['genetics']!['genes'] = 5))).toContain( + 'genetics.genes must be an object' + ) + }) + + it('rejects non-numeric range overrides', () => { + expect( + errorsFor((c) => (loose(c)['genetics']!['genes'] = { 'physical.radius': { min: 'big' } })) + ).toContain('genetics.genes.physical.radius.min must be a finite number') + }) + + it('validates the effective range of an overridden gene', () => { + expect( + errorsFor((c) => (c.genetics.genes['physical.maxSpeed'] = { min: 30, max: 30 })) + ).toContain('genetics.genes.physical.maxSpeed: min must be less than max') + expect( + errorsFor((c) => (c.genetics.genes['physical.maxSpeed'] = { mutationRate: 2 })) + ).toContain('genetics.genes.physical.maxSpeed: mutationRate must be between zero and one') + expect( + errorsFor((c) => (c.genetics.genes['physical.maxSpeed'] = { mutationStd: -1 })) + ).toContain('genetics.genes.physical.maxSpeed: mutationStd must be nonnegative') + }) + + it('requires the founder baseline to lie inside the gene range', () => { + expect( + errorsFor((c) => (c.genetics.genes['physical.maxSpeed'] = { min: 25, max: 40 })) + ).toContain( + 'genetics.genes.physical.maxSpeed: the founder baseline (20) must lie within [min, max]' + ) + }) + + it('validates organisms.hue as a turn in [0, 1)', () => { + expect(errorsFor((c) => (c.organisms.hue = 1))).toContain( + 'organisms.hue must be at least zero and less than one' + ) + expect(errorsFor((c) => (c.organisms.hue = -0.1))).toContain( + 'organisms.hue must be at least zero and less than one' + ) + expect(errorsFor((c) => (c.organisms.hue = 0))).toEqual([]) + }) + + it('accepts a changed baseline without a matching range override', () => { + expect( + errorsFor((c) => { + c.organisms.maxSpeed = 0 + c.organisms.maturityAge = 1 + c.organisms.senescenceAge = 5 + c.organisms.maxAge = 10 + c.reproduction.cooldown = 5 + }) + ).toEqual([]) + }) + + it('requires each ordered gene range to extend above the gene it must follow', () => { + expect(errorsFor((c) => (c.genetics.genes['physical.senescenceAge'] = { max: 170 }))).toContain( + 'the physical.senescenceAge gene range max must be greater than the physical.maturityAge gene range max' + ) + expect(errorsFor((c) => (c.genetics.genes['physical.maxAge'] = { max: 560 }))).toContain( + 'the physical.maxAge gene range max must be greater than the physical.senescenceAge gene range max' + ) + }) + + it('requires a positive minimum offspringInvestment', () => { + expect( + errorsFor((c) => (c.genetics.genes['reproduction.offspringInvestment'] = { min: 0 })) + ).toContain('genetics.genes.reproduction.offspringInvestment: min must be greater than zero') + }) + + it('reports missing genetics sections', () => { + const errors = errorsFor((c) => { + delete loose(c)['genetics']!['brain'] + delete loose(c)['genetics']!['genes'] + }) + expect(errors).toContain('genetics.brain is required') + expect(errors).toContain('genetics.genes is required') + }) +}) + +describe('genetics merging', () => { + it('returns genetics defaults when given nothing', () => { + expect(resolveWorldConfig().genetics).toEqual(DEFAULT_WORLD_CONFIG.genetics) + }) + + it('merges nested genetics sections field by field', () => { + const config = resolveWorldConfig({ + genetics: { + mutationRateScale: 2, + brain: { hiddenSize: 12 }, + learning: { explorationRate: 0.3 }, + learningInheritance: { mode: 'lamarckian' }, + genes: { 'physical.maxSpeed': { max: 50 } } + } + }) + expect(config.genetics.mutationRateScale).toBe(2) + expect(config.genetics.mutationStrengthScale).toBe(1) + expect(config.genetics.brain.hiddenSize).toBe(12) + expect(config.genetics.brain.inputSize).toBe(16) + expect(config.genetics.learning.explorationRate).toBe(0.3) + expect(config.genetics.learning.persistence).toBe(0.5) + expect(config.genetics.learningInheritance).toEqual({ mode: 'lamarckian', fraction: 0.15 }) + expect(config.genetics.genes).toEqual({ 'physical.maxSpeed': { max: 50 } }) + }) + + it('does not mutate the shared frozen defaults', () => { + const config = resolveWorldConfig({ genetics: { brain: { hiddenSize: 12 } } }) + config.genetics.brain.hiddenSize = 99 + expect(DEFAULT_WORLD_CONFIG.genetics.brain.hiddenSize).toBe(8) + }) + + it('reports genetics overrides that are not objects', () => { + expect(() => resolveWorldConfig({ genetics: 5 as never })).toThrow(/genetics must be an object/) + expect(() => resolveWorldConfig({ genetics: { brain: 5 as never } })).toThrow( + /genetics.brain must be an object/ + ) + }) + + it('throws listing a genetics problem found after merging', () => { + expect(() => resolveWorldConfig({ genetics: { brain: { inputSize: 0 } } })).toThrow( + /genetics.brain.inputSize must be an integer greater than zero/ + ) + }) +}) diff --git a/packages/config/tests/validation.test.ts b/packages/config/tests/validation.test.ts index d2f23b3..8f8802b 100644 --- a/packages/config/tests/validation.test.ts +++ b/packages/config/tests/validation.test.ts @@ -119,11 +119,11 @@ describe('validateWorldConfig', () => { expect(errorsFor((c) => (c.reproduction.minEnergy = 0))).toContain( 'reproduction.minEnergy must be greater than zero' ) - expect(errorsFor((c) => (c.reproduction.energyCost = 0))).toContain( - 'reproduction.energyCost must be greater than zero' + expect(errorsFor((c) => (c.reproduction.offspringInvestment = 0))).toContain( + 'reproduction.offspringInvestment must be greater than zero' ) - expect(errorsFor((c) => (c.reproduction.offspringEnergy = 0))).toContain( - 'reproduction.offspringEnergy must be greater than zero' + expect(errorsFor((c) => (c.reproduction.overheadFraction = -0.1))).toContain( + 'reproduction.overheadFraction must be nonnegative' ) expect(errorsFor((c) => (c.reproduction.cooldown = -1))).toContain( 'reproduction.cooldown must be nonnegative' @@ -224,12 +224,14 @@ describe('validateWorldConfig', () => { expect(errorsFor((c) => (c.organisms.senescenceAge = c.organisms.maxAge))).toContain( 'organisms.senescenceAge must be less than organisms.maxAge' ) - expect(errorsFor((c) => (c.reproduction.energyCost = c.reproduction.minEnergy + 1))).toContain( - 'reproduction.energyCost must not exceed reproduction.minEnergy' + expect( + errorsFor((c) => (c.reproduction.offspringInvestment = c.reproduction.minEnergy)) + ).toContain( + 'reproduction.offspringInvestment * (1 + reproduction.overheadFraction) must not exceed reproduction.minEnergy' ) expect( - errorsFor((c) => (c.reproduction.offspringEnergy = c.organisms.maxEnergy + 1)) - ).toContain('reproduction.offspringEnergy must not exceed organisms.maxEnergy') + errorsFor((c) => (c.reproduction.offspringInvestment = c.organisms.maxEnergy)) + ).toContain('twice reproduction.offspringInvestment must not exceed organisms.maxEnergy') }) it('reports every problem at once, not just the first', () => { diff --git a/packages/contracts/src/genome.ts b/packages/contracts/src/genome.ts new file mode 100644 index 0000000..e45c9e2 --- /dev/null +++ b/packages/contracts/src/genome.ts @@ -0,0 +1,231 @@ +import type { EntityId } from './entity' + +/** + * Identifies a genome. Allocated from its own monotonic counter, separate from `EntityId`, so + * genome identity never depends on how many resources have spawned (docs/decisions/0006). + */ +export type GenomeId = number + +/** Body and life-history genes. Ages are in seconds; `hue` is in turns, `[0, 1)`. */ +export interface PhysicalGenes { + readonly radius: number + readonly maxSpeed: number + readonly maxTurnRate: number + readonly maturityAge: number + readonly senescenceAge: number + readonly maxAge: number + readonly hue: number +} + +export interface SensoryGenes { + readonly sensorRadius: number +} + +export interface MetabolicGenes { + readonly basalCost: number + readonly movementCost: number + readonly turningCost: number +} + +export interface BehavioralGenes { + readonly wanderJitter: number +} + +export interface ReproductionGenes { + readonly minEnergy: number + /** Energy this parent contributes to the child; the parent pays more (see `overheadFraction`). */ + readonly offspringInvestment: number + readonly cooldown: number + readonly searchRadius: number +} + +/** Fixed brain dimensions shared by every organism in a world (topology does not evolve). */ +export interface BrainArchitecture { + readonly inputSize: number + readonly hiddenSize: number + readonly memorySize: number + readonly outputSize: number +} + +export interface BrainGenome { + readonly architecture: BrainArchitecture + /** One flat vector made of the consecutive blocks documented in docs/genetics/genome.md. */ + readonly weights: readonly number[] +} + +/** Carried genes: inherited and mutated now, first read by the learning system in Phase 5. */ +export interface LearningGenes { + readonly learningRate: number + readonly discountFactor: number + readonly eligibilityTraceDecay: number + readonly explorationRate: number + readonly riskTolerance: number + readonly noveltyPreference: number + readonly persistence: number +} + +/** Immutable. One per organism; a child's genome is built from both parents' genomes. */ +export interface Genome { + readonly version: 1 + readonly id: GenomeId + /** Empty for founders; otherwise the two parents' genome ids, ascending. */ + readonly parentIds: readonly GenomeId[] + readonly physical: PhysicalGenes + readonly sensory: SensoryGenes + readonly metabolic: MetabolicGenes + readonly behavioral: BehavioralGenes + readonly reproduction: ReproductionGenes + readonly brain: BrainGenome + readonly learning: LearningGenes +} + +export type GeneGroup = + 'physical' | 'sensory' | 'metabolic' | 'behavioral' | 'reproduction' | 'learning' + +/** + * How a gene mutates and recombines. `int` and `bool` genes exist so the operators are complete + * (roadmap 4.3); the production gene list has none, and tests exercise them with synthetic tables. + */ +export type GeneKind = 'float' | 'int' | 'bool' | 'circular' + +export type GenePath = + | 'physical.radius' + | 'physical.maxSpeed' + | 'physical.maxTurnRate' + | 'physical.maturityAge' + | 'physical.senescenceAge' + | 'physical.maxAge' + | 'physical.hue' + | 'sensory.sensorRadius' + | 'metabolic.basalCost' + | 'metabolic.movementCost' + | 'metabolic.turningCost' + | 'behavioral.wanderJitter' + | 'reproduction.minEnergy' + | 'reproduction.offspringInvestment' + | 'reproduction.cooldown' + | 'reproduction.searchRadius' + | 'learning.learningRate' + | 'learning.discountFactor' + | 'learning.eligibilityTraceDecay' + | 'learning.explorationRate' + | 'learning.riskTolerance' + | 'learning.noveltyPreference' + | 'learning.persistence' + +export interface GeneDefinition { + readonly path: GenePath + readonly kind: GeneKind + /** Mutation size is a fraction of the gene's current value instead of its range width. */ + readonly relative?: true + /** + * This gene's value must stay strictly above the named gene's value. Mutation and + * recombination enforce it by raising the lower bound, so ordered genes (the life-stage ages) + * stay ordered for any ranges. The named gene must come earlier in `GENE_DEFINITIONS`. + */ + readonly orderedAfter?: GenePath +} + +/** + * Every scalar gene in its fixed canonical order. This order is used for mutation draws, the + * state hash and genome comparison, so it must never be reordered (append only, with a hash + * schema bump). Nothing iterates a genome by object key. + */ +export const GENE_DEFINITIONS: readonly GeneDefinition[] = [ + { path: 'physical.radius', kind: 'float' }, + { path: 'physical.maxSpeed', kind: 'float' }, + { path: 'physical.maxTurnRate', kind: 'float' }, + { path: 'physical.maturityAge', kind: 'float' }, + { path: 'physical.senescenceAge', kind: 'float', orderedAfter: 'physical.maturityAge' }, + { path: 'physical.maxAge', kind: 'float', orderedAfter: 'physical.senescenceAge' }, + { path: 'physical.hue', kind: 'circular' }, + { path: 'sensory.sensorRadius', kind: 'float' }, + { path: 'metabolic.basalCost', kind: 'float' }, + { path: 'metabolic.movementCost', kind: 'float' }, + { path: 'metabolic.turningCost', kind: 'float' }, + { path: 'behavioral.wanderJitter', kind: 'float' }, + { path: 'reproduction.minEnergy', kind: 'float' }, + { path: 'reproduction.offspringInvestment', kind: 'float' }, + { path: 'reproduction.cooldown', kind: 'float' }, + { path: 'reproduction.searchRadius', kind: 'float' }, + { path: 'learning.learningRate', kind: 'float', relative: true }, + { path: 'learning.discountFactor', kind: 'float' }, + { path: 'learning.eligibilityTraceDecay', kind: 'float' }, + { path: 'learning.explorationRate', kind: 'float' }, + { path: 'learning.riskTolerance', kind: 'float' }, + { path: 'learning.noveltyPreference', kind: 'float' }, + { path: 'learning.persistence', kind: 'float' } +] + +/** Bounds and mutation parameters of one gene. */ +export interface GeneRange { + /** Inclusive lower bound (for a circular gene, the start of the period). */ + min: number + /** Inclusive upper bound (for a circular gene, the exclusive end of the period). */ + max: number + /** Probability in `[0, 1]` that the gene mutates in one birth. */ + mutationRate: number + /** Standard deviation: a fraction of the range width (or of the value, for relative genes). */ + mutationStd: number +} + +export type GeneRangeOverrides = Partial>> + +export type LearningInheritanceMode = 'baldwinian' | 'partial' | 'lamarckian' + +export interface LearningInheritanceConfig { + mode: LearningInheritanceMode + /** Share of learned change inherited in `'partial'` mode, in `[0, 1]`. */ + fraction: number +} + +export interface BrainConfig { + inputSize: number + hiddenSize: number + memorySize: number + outputSize: number + /** Standard deviation of the Gaussian that draws each founder weight. */ + initialWeightStd: number + /** Probability in `[0, 1]` that one weight mutates in a birth. */ + weightMutationRate: number + weightMutationStd: number +} + +/** Founder baseline for the learning genes (the other baselines come from `organisms.*`). */ +export type LearningBaseline = { -readonly [K in keyof LearningGenes]: LearningGenes[K] } + +export interface GeneticsConfig { + /** Multiplies every mutation rate; `0` disables mutation. */ + mutationRateScale: number + /** Multiplies every mutation standard deviation; `0` disables mutation. */ + mutationStrengthScale: number + /** Blend-crossover extension past the parents' values, as a fraction of their difference. */ + blendAlpha: number + brain: BrainConfig + learning: LearningBaseline + learningInheritance: LearningInheritanceConfig + /** Per-gene overrides of the default ranges and rates (keyed by gene path). */ + genes: GeneRangeOverrides +} + +/** Genome-level lineage bookkeeping (docs/genetics/genome.md). */ +export interface LineageRecord { + readonly genomeId: GenomeId + /** Empty for founders. */ + readonly parentGenomeIds: readonly GenomeId[] + readonly organismIds: readonly EntityId[] + readonly createdAtTick: number + /** Total descendants ever (computed on demand, not maintained per birth). */ + readonly descendantCount: number +} + +/** A partial genetics configuration; nested sections merge field by field onto the defaults. */ +export interface GeneticsConfigOverrides { + mutationRateScale?: number + mutationStrengthScale?: number + blendAlpha?: number + brain?: Partial + learning?: Partial + learningInheritance?: Partial + genes?: GeneRangeOverrides +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index b9c5a5b..cf15d47 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -2,6 +2,32 @@ export type { Vec2, Rect, Circle } from './math' export type { EntityId } from './entity' export type { ValidationResult } from './validation' export type { LifeStage } from './organism' +export { GENE_DEFINITIONS } from './genome' +export type { + GenomeId, + Genome, + PhysicalGenes, + SensoryGenes, + MetabolicGenes, + BehavioralGenes, + ReproductionGenes, + BrainArchitecture, + BrainGenome, + LearningGenes, + GeneGroup, + GeneKind, + GenePath, + GeneDefinition, + GeneRange, + GeneRangeOverrides, + LearningInheritanceMode, + LearningInheritanceConfig, + BrainConfig, + LearningBaseline, + GeneticsConfig, + GeneticsConfigOverrides, + LineageRecord +} from './genome' export { MAX_SEED } from './world' export type { DeathCause, diff --git a/packages/contracts/src/world.ts b/packages/contracts/src/world.ts index 9381d97..f95f4d5 100644 --- a/packages/contracts/src/world.ts +++ b/packages/contracts/src/world.ts @@ -1,3 +1,5 @@ +import type { GeneticsConfig, GeneticsConfigOverrides } from './genome' + /** Largest valid world seed (seeds are unsigned 32-bit integers). */ export const MAX_SEED = 4_294_967_295 @@ -39,6 +41,8 @@ export interface OrganismConfig { turningCost: number /** Flat energy lost per second for maintaining senses. */ sensorCost: number + /** Founder baseline hue in turns, `[0, 1)` (cosmetic; the `hue` gene's starting value). */ + hue: number /** Age in seconds at which an organism dies of old age. */ maxAge: number /** Age in seconds below which an organism is a juvenile: cannot reproduce, smaller and slower. */ @@ -72,10 +76,10 @@ export interface ResourceConfig { export interface ReproductionConfig { /** Energy required to attempt reproduction. */ minEnergy: number - /** Energy each parent pays on a successful reproduction. */ - energyCost: number - /** The newborn's starting energy (capped at `organisms.maxEnergy` as usual). */ - offspringEnergy: number + /** Energy each parent gives the child; the child receives the sum, capped by its capacity. */ + offspringInvestment: number + /** Fraction of the investment lost in the transfer: a parent pays `investment * (1 + overheadFraction)`. */ + overheadFraction: number /** Seconds before either parent may reproduce again. */ cooldown: number /** Distance within which a partner is sought. */ @@ -104,6 +108,7 @@ export interface WorldConfig { resources: ResourceConfig reproduction: ReproductionConfig history: HistoryConfig + genetics: GeneticsConfig } /** A partial configuration merged onto the defaults. */ @@ -116,6 +121,7 @@ export interface WorldConfigOverrides { resources?: Partial reproduction?: Partial history?: Partial + genetics?: GeneticsConfigOverrides } export interface WorldOptions { diff --git a/packages/simulation/src/genetics/GeneSpecs.ts b/packages/simulation/src/genetics/GeneSpecs.ts new file mode 100644 index 0000000..fc220ea --- /dev/null +++ b/packages/simulation/src/genetics/GeneSpecs.ts @@ -0,0 +1,56 @@ +import { GENE_DEFINITIONS } from '@evo/contracts' +import type { GeneKind, WorldConfig } from '@evo/contracts' +import { geneBaselines, resolveGeneRanges } from '@evo/config' + +/** + * One gene as the operators see it. A table of these (in a fixed order) is all the mutation, + * recombination, validation and comparison code needs, so the operators are generic over the + * table and tests can drive them with synthetic tables that contain integer, boolean, circular + * and correlated genes the production table does not have. + */ +export interface GeneSpec { + /** Dotted path such as `physical.maxSpeed`; the group is the part before the first dot. */ + readonly path: string + readonly kind: GeneKind + readonly min: number + readonly max: number + /** Probability in `[0, 1]` that the gene mutates in one birth (before the global scale). */ + readonly mutationRate: number + /** Standard deviation: a fraction of the range width, or of the value when `relative`. */ + readonly mutationStd: number + /** The founder value. */ + readonly baseline: number + /** Mutation size is relative to the current value instead of the range width. */ + readonly relative: boolean + /** Genes sharing a group receive one shared mutation draw (trait-shift). Floats/circular only. */ + readonly correlationGroup?: string + /** Index, in the same table, of a gene this one must stay strictly above; `-1` if none. */ + readonly orderedAfter: number +} + +/** + * Gap kept between an ordered gene and the gene it must follow. Ages are in seconds, so this is + * far below anything the simulation can observe while still keeping the order strict. + */ +export const ORDER_MARGIN = 1e-6 + +/** The production gene table for a world configuration, in canonical order. */ +export function buildGeneSpecs(config: WorldConfig): GeneSpec[] { + const baselines = geneBaselines(config) + const ranges = resolveGeneRanges(config.genetics.genes, baselines) + const indexOf = (path: string): number => GENE_DEFINITIONS.findIndex((d) => d.path === path) + return GENE_DEFINITIONS.map((definition) => { + const range = ranges[definition.path] + return { + path: definition.path, + kind: definition.kind, + min: range.min, + max: range.max, + mutationRate: range.mutationRate, + mutationStd: range.mutationStd, + baseline: baselines[definition.path], + relative: definition.relative === true, + orderedAfter: definition.orderedAfter === undefined ? -1 : indexOf(definition.orderedAfter) + } + }) +} diff --git a/packages/simulation/src/genetics/GeneticDistance.ts b/packages/simulation/src/genetics/GeneticDistance.ts new file mode 100644 index 0000000..6fe2155 --- /dev/null +++ b/packages/simulation/src/genetics/GeneticDistance.ts @@ -0,0 +1,83 @@ +import type { Genome } from '@evo/contracts' +import { shortestDelta } from './geneMath' +import type { GeneSpec } from './GeneSpecs' +import { genomeToVector, sameArchitecture } from './GenomeFactory' + +/** Each field is in `[0, 1]`: `0` for identical, `1` for maximally different. */ +export interface GenomeDistance { + /** The unweighted mean of the seven group distances. */ + total: number + physical: number + sensory: number + metabolic: number + behavioral: number + reproduction: number + brain: number + learning: number +} + +type ScalarGroup = 'physical' | 'sensory' | 'metabolic' | 'behavioral' | 'reproduction' | 'learning' + +/** Distance between two gene values: range-normalized, shortest-arc for circular genes. */ +function geneDistance(spec: GeneSpec, a: number, b: number): number { + const span = spec.max - spec.min + if (spec.kind === 'circular') { + return Math.abs(shortestDelta(a, b, span)) / (span / 2) + } + return Math.min(1, Math.abs(a - b) / span) +} + +/** + * Compares two genomes (roadmap: "parent and child genomes can be compared"). Scalar genes are + * normalized by range width (circular genes by the shortest arc), a group's distance is the mean + * over its genes, and the brain is the RMS weight difference divided by `2 * initialWeightStd` + * (clamped to 1). Symmetric, and `0` exactly when the genomes carry the same values. + */ +export function genomeDistance( + a: Genome, + b: Genome, + specs: readonly GeneSpec[], + initialWeightStd: number +): GenomeDistance { + if (!sameArchitecture(a.brain.architecture, b.brain.architecture)) { + throw new RangeError('genomes must share one brain architecture to be compared') + } + const left = genomeToVector(a) + const right = genomeToVector(b) + + const sums: Record = { + physical: { total: 0, count: 0 }, + sensory: { total: 0, count: 0 }, + metabolic: { total: 0, count: 0 }, + behavioral: { total: 0, count: 0 }, + reproduction: { total: 0, count: 0 }, + learning: { total: 0, count: 0 } + } + specs.forEach((spec, index) => { + const group = spec.path.split('.')[0] as ScalarGroup + const bucket = sums[group] + bucket.total += geneDistance(spec, left[index] as number, right[index] as number) + bucket.count += 1 + }) + const groupDistance = (group: ScalarGroup): number => + sums[group].count === 0 ? 0 : sums[group].total / sums[group].count + + let squared = 0 + const weightsA = a.brain.weights + const weightsB = b.brain.weights + for (let i = 0; i < weightsA.length; i++) { + const difference = (weightsA[i] as number) - (weightsB[i] as number) + squared += difference * difference + } + const rms = weightsA.length === 0 ? 0 : Math.sqrt(squared / weightsA.length) + const brain = Math.min(1, rms / (2 * initialWeightStd)) + + const physical = groupDistance('physical') + const sensory = groupDistance('sensory') + const metabolic = groupDistance('metabolic') + const behavioral = groupDistance('behavioral') + const reproduction = groupDistance('reproduction') + const learning = groupDistance('learning') + const total = (physical + sensory + metabolic + behavioral + reproduction + brain + learning) / 7 + return { total, physical, sensory, metabolic, behavioral, reproduction, brain, learning } +} diff --git a/packages/simulation/src/genetics/GenomeFactory.ts b/packages/simulation/src/genetics/GenomeFactory.ts new file mode 100644 index 0000000..8b61bd9 --- /dev/null +++ b/packages/simulation/src/genetics/GenomeFactory.ts @@ -0,0 +1,128 @@ +import { GENE_DEFINITIONS } from '@evo/contracts' +import type { BrainArchitecture, BrainConfig, Genome, GenomeId } from '@evo/contracts' +import type { RandomSource } from '../random/RandomSource' +import type { GeneSpec } from './GeneSpecs' + +/** + * The brain weight blocks, in storage order (docs/genetics/genome.md). Changing this order or + * layout changes what every stored weight means, so it is a hash-schema change. + */ +export function brainBlockLengths(architecture: BrainArchitecture): number[] { + const { inputSize, hiddenSize, memorySize, outputSize } = architecture + return [ + hiddenSize * inputSize, // inputToHidden + hiddenSize * memorySize, // memoryToHidden + hiddenSize, // hiddenBias + memorySize * hiddenSize, // hiddenToMemory + memorySize, // memoryBias + outputSize * hiddenSize, // hiddenToOutput + outputSize // outputBias + ] +} + +export function brainWeightCount(architecture: BrainArchitecture): number { + let total = 0 + for (const length of brainBlockLengths(architecture)) total += length + return total +} + +export function sameArchitecture(a: BrainArchitecture, b: BrainArchitecture): boolean { + return ( + a.inputSize === b.inputSize && + a.hiddenSize === b.hiddenSize && + a.memorySize === b.memorySize && + a.outputSize === b.outputSize + ) +} + +export function architectureOf(brain: BrainConfig | BrainArchitecture): BrainArchitecture { + return { + inputSize: brain.inputSize, + hiddenSize: brain.hiddenSize, + memorySize: brain.memorySize, + outputSize: brain.outputSize + } +} + +type GroupedGenes = Record> + +/** The scalar genes of a genome as a flat vector in `GENE_DEFINITIONS` order. */ +export function genomeToVector(genome: Genome): number[] { + const groups = genome as unknown as GroupedGenes + return GENE_DEFINITIONS.map((definition) => { + const [group, name] = definition.path.split('.') as [string, string] + return (groups[group] as Record)[name] as number + }) +} + +export interface GenomeParts { + id: GenomeId + parentIds: readonly GenomeId[] + /** One value per `GENE_DEFINITIONS` entry, in order. */ + vector: readonly number[] + architecture: BrainArchitecture + weights: readonly number[] +} + +/** Assembles an immutable genome from a gene vector and brain weights. */ +export function genomeFromParts(parts: GenomeParts): Genome { + const groups: GroupedGenes = { + physical: {}, + sensory: {}, + metabolic: {}, + behavioral: {}, + reproduction: {}, + learning: {} + } + GENE_DEFINITIONS.forEach((definition, index) => { + const [group, name] = definition.path.split('.') as [string, string] + ;(groups[group] as Record)[name] = parts.vector[index] as number + }) + const genome = { + version: 1, + id: parts.id, + parentIds: [...parts.parentIds], + ...groups, + brain: { architecture: { ...parts.architecture }, weights: [...parts.weights] } + } as unknown as Genome + return freezeGenome(genome) +} + +/** Deep-freezes a genome so accidental mutation throws instead of silently corrupting a lineage. */ +function freezeGenome(genome: Genome): Genome { + Object.freeze(genome.parentIds) + Object.freeze(genome.physical) + Object.freeze(genome.sensory) + Object.freeze(genome.metabolic) + Object.freeze(genome.behavioral) + Object.freeze(genome.reproduction) + Object.freeze(genome.learning) + Object.freeze(genome.brain.architecture) + Object.freeze(genome.brain.weights) + Object.freeze(genome.brain) + return Object.freeze(genome) +} + +/** + * A founder: every scalar gene at its baseline and brain weights drawn from the supplied stream + * (Gaussian, one draw per weight in storage order). Founders are identical except for their + * brains; variation in the other genes enters only at births. + */ +export function createFounderGenome( + id: GenomeId, + specs: readonly GeneSpec[], + brain: BrainConfig, + random: RandomSource +): Genome { + const architecture = architectureOf(brain) + const count = brainWeightCount(architecture) + const weights: number[] = [] + for (let i = 0; i < count; i++) weights.push(random.normal(0, brain.initialWeightStd)) + return genomeFromParts({ + id, + parentIds: [], + vector: specs.map((spec) => spec.baseline), + architecture, + weights + }) +} diff --git a/packages/simulation/src/genetics/GenomeValidation.ts b/packages/simulation/src/genetics/GenomeValidation.ts new file mode 100644 index 0000000..3f64868 --- /dev/null +++ b/packages/simulation/src/genetics/GenomeValidation.ts @@ -0,0 +1,153 @@ +import type { BrainArchitecture, Genome, GenomeId, ValidationResult } from '@evo/contracts' +import { boundGene } from './geneMath' +import type { GeneSpec } from './GeneSpecs' +import { + brainWeightCount, + genomeFromParts, + genomeToVector, + sameArchitecture +} from './GenomeFactory' + +/** + * Checks a gene vector against a spec table: every value finite and inside its range, integers + * integral, booleans 0 or 1, and ordered genes strictly above the gene they follow. Reports every + * violation; never repairs. + */ +export function validateGeneValues( + values: readonly number[], + specs: readonly GeneSpec[] +): string[] { + const errors: string[] = [] + if (values.length !== specs.length) { + errors.push(`expected ${specs.length} genes but found ${values.length}`) + return errors + } + specs.forEach((spec, index) => { + const value = values[index] as number + if (typeof value !== 'number' || !Number.isFinite(value)) { + errors.push(`${spec.path} is not a finite number`) + return + } + if (spec.kind === 'bool') { + if (value !== 0 && value !== 1) errors.push(`${spec.path} must be 0 or 1`) + return + } + if (spec.kind === 'int' && !Number.isInteger(value)) { + errors.push(`${spec.path} must be an integer`) + } + const insideRange = + spec.kind === 'circular' + ? value >= spec.min && value < spec.max + : value >= spec.min && value <= spec.max + if (!insideRange) { + errors.push(`${spec.path} (${value}) is outside its range [${spec.min}, ${spec.max}]`) + } + if (spec.orderedAfter >= 0) { + const earlier = specs[spec.orderedAfter] as GeneSpec + if (!(value > (values[spec.orderedAfter] as number))) { + errors.push(`${spec.path} must be greater than ${earlier.path}`) + } + } + }) + return errors +} + +export interface GenomeValidationContext { + /** The architecture every brain in the world must have. */ + brain: BrainArchitecture + /** When given, genome ids must be below it (the next id the world will allocate). */ + nextGenomeId?: GenomeId +} + +/** + * Validates a genome (roadmap 4.5): its genes against `specs`, its identity, and its brain. + * Reports every violation found and never modifies anything. + */ +export function validateGenome( + genome: Genome, + specs: readonly GeneSpec[], + context: GenomeValidationContext +): ValidationResult { + const errors: string[] = [] + + if (genome.version !== 1) errors.push(`unsupported genome version ${String(genome.version)}`) + if (!Number.isInteger(genome.id) || genome.id < 0) { + errors.push(`genome id (${genome.id}) must be a nonnegative integer`) + } else if (context.nextGenomeId !== undefined && genome.id >= context.nextGenomeId) { + errors.push(`genome id (${genome.id}) has not been allocated yet`) + } + let previousParent = -1 + for (const parentId of genome.parentIds) { + if (!Number.isInteger(parentId) || parentId < 0 || parentId >= genome.id) { + errors.push(`parent id (${parentId}) must be a nonnegative integer below the genome id`) + } + if (parentId <= previousParent) errors.push('parent ids must be strictly ascending') + previousParent = parentId + } + + errors.push(...validateGeneValues(genomeToVector(genome), specs)) + + const { learning, reproduction } = genome + if (!(learning.learningRate >= 0)) errors.push('learningRate must be nonnegative') + if (!(learning.discountFactor >= 0 && learning.discountFactor <= 1)) { + errors.push('discountFactor must be between zero and one') + } + if (!(reproduction.offspringInvestment > 0)) errors.push('offspringInvestment must be positive') + + const { architecture, weights } = genome.brain + const dimensionsValid = [ + architecture.inputSize, + architecture.hiddenSize, + architecture.memorySize, + architecture.outputSize + ].every((size) => Number.isInteger(size) && size > 0) + if (!dimensionsValid) { + errors.push('brain dimensions must be positive integers') + } else if (!sameArchitecture(architecture, context.brain)) { + errors.push('brain architecture does not match the world configuration') + } else if (weights.length !== brainWeightCount(architecture)) { + errors.push( + `brain has ${weights.length} weights but its architecture needs ${brainWeightCount(architecture)}` + ) + } + if (weights.some((weight) => typeof weight !== 'number' || !Number.isFinite(weight))) { + errors.push('brain weights must all be finite numbers') + } + + return { ok: errors.length === 0, errors } +} + +/** + * Forces a gene vector into its legal set: non-finite values reset to the baseline, values + * clamped (ordered genes kept in order), integers rounded, booleans snapped to 0 or 1 and + * circular genes wrapped. + */ +export function repairGeneValues(values: readonly number[], specs: readonly GeneSpec[]): number[] { + const repaired: number[] = [] + specs.forEach((spec, index) => { + let value = values[index] as number + if (typeof value !== 'number' || !Number.isFinite(value)) value = spec.baseline + if (spec.kind === 'bool') { + repaired.push(value >= 0.5 ? 1 : 0) + return + } + if (spec.kind === 'int') value = Math.round(value) + repaired.push(boundGene(spec, repaired, value)) + }) + return repaired +} + +/** + * Returns a copy of the genome with every gene forced into its legal set (see + * `repairGeneValues`) and non-finite weights zeroed. A tooling helper only - the engine never + * repairs a genome, it rejects an invalid one (roadmap 4.5). + */ +export function repairGenome(genome: Genome, specs: readonly GeneSpec[]): Genome { + return genomeFromParts({ + id: genome.id, + parentIds: genome.parentIds, + vector: repairGeneValues(genomeToVector(genome), specs), + architecture: genome.brain.architecture, + weights: genome.brain.weights.map((weight) => (Number.isFinite(weight) ? weight : 0)) + }) +} diff --git a/packages/simulation/src/genetics/LearningInheritance.ts b/packages/simulation/src/genetics/LearningInheritance.ts new file mode 100644 index 0000000..b6df06a --- /dev/null +++ b/packages/simulation/src/genetics/LearningInheritance.ts @@ -0,0 +1,37 @@ +import type { LearningInheritanceMode } from '@evo/contracts' + +/** + * The brain weights a child should start from, given its parent's genome weights and the weights + * that parent had learned by the time of reproduction (roadmap 4.7): + * + * - `baldwinian`: learning shapes the parent's life but is never inherited - the genome weights; + * - `lamarckian`: everything the parent learned is inherited - the learned weights; + * - `partial`: `genome + fraction * (learned - genome)`, so `fraction` of the learned change. + * + * Pure. Phase 4 defines and tests it; the learning system that produces `learnedWeights` + * arrives in Phase 5, until which `ReproductionSystem` passes genome weights only. + */ +export function inheritedBrainWeights( + genomeWeights: readonly number[], + learnedWeights: readonly number[], + mode: LearningInheritanceMode, + fraction: number +): number[] { + if (genomeWeights.length !== learnedWeights.length) { + throw new RangeError('genome and learned weights must have the same length') + } + switch (mode) { + case 'baldwinian': + return genomeWeights.slice() + case 'lamarckian': + return learnedWeights.slice() + case 'partial': { + if (!(fraction >= 0 && fraction <= 1)) { + throw new RangeError('fraction must be between zero and one') + } + return genomeWeights.map( + (genome, index) => genome + fraction * ((learnedWeights[index] as number) - genome) + ) + } + } +} diff --git a/packages/simulation/src/genetics/Mutation.ts b/packages/simulation/src/genetics/Mutation.ts new file mode 100644 index 0000000..dadeaa8 --- /dev/null +++ b/packages/simulation/src/genetics/Mutation.ts @@ -0,0 +1,104 @@ +import type { RandomSource } from '../random/RandomSource' +import { boundGene } from './geneMath' +import type { GeneSpec } from './GeneSpecs' + +export interface MutationScales { + /** Multiplies every mutation rate. `0` disables mutation. */ + rate: number + /** Multiplies every mutation standard deviation. `0` disables mutation. */ + strength: number +} + +interface GroupDraw { + fire: boolean + z: number +} + +/** + * Mutates a gene vector, returning a new one (the input is never modified). Each gene mutates + * with its own operator and its own bounds (docs/genetics/genome.md): + * + * - float: Gaussian step of `std * range` (or `std * |value|` for a relative gene), clamped; + * - integer: step of one in a random direction, clamped; + * - boolean: flipped; + * - circular: Gaussian step on the circle, wrapped; + * - genes sharing a `correlationGroup` move together by one shared draw (trait-shift), each + * scaled to its own range. + * + * Each independent gene (or correlated group) consumes the same three draws (gate, normal, coin) + * whether or not it mutates and whatever the configured rates are, so a configuration change + * never shifts later draws. Ordered genes are re-bounded even when they do not mutate, because + * the gene they must follow may have moved. + */ +export function mutateVector( + values: readonly number[], + specs: readonly GeneSpec[], + random: RandomSource, + scales: MutationScales +): number[] { + const out = values.slice() + const groups = new Map() + + specs.forEach((spec, index) => { + const current = out[index] as number + let draw: GroupDraw + let coin: boolean + const shared = + spec.correlationGroup === undefined ? undefined : groups.get(spec.correlationGroup) + if (shared) { + draw = shared + coin = false + } else { + const gate = random.float() + const z = random.normal() + coin = random.boolean() + draw = { fire: gate < Math.min(1, spec.mutationRate * scales.rate), z } + if (spec.correlationGroup !== undefined) groups.set(spec.correlationGroup, draw) + } + + let candidate = current + if (draw.fire) { + const std = spec.mutationStd * scales.strength + const span = spec.max - spec.min + switch (spec.kind) { + case 'float': + candidate = current + draw.z * std * (spec.relative ? Math.abs(current) : span) + break + case 'circular': + candidate = current + draw.z * std * span + break + case 'int': + candidate = current + (coin ? 1 : -1) + break + case 'bool': + candidate = current === 0 ? 1 : 0 + break + } + } + out[index] = spec.kind === 'bool' ? candidate : boundGene(spec, out, candidate) + }) + return out +} + +export interface WeightMutation { + /** Probability in `[0, 1]` that one weight mutates. */ + rate: number + /** Standard deviation of the Gaussian step. */ + std: number +} + +/** Mutates brain weights independently (sparse Gaussian), returning a new array. */ +export function mutateWeights( + weights: readonly number[], + random: RandomSource, + mutation: WeightMutation, + scales: MutationScales +): number[] { + const probability = Math.min(1, mutation.rate * scales.rate) + const std = mutation.std * scales.strength + return weights.map((weight) => { + const gate = random.float() + const z = random.normal() + return gate < probability ? weight + z * std : weight + }) +} diff --git a/packages/simulation/src/genetics/Recombination.ts b/packages/simulation/src/genetics/Recombination.ts new file mode 100644 index 0000000..f128014 --- /dev/null +++ b/packages/simulation/src/genetics/Recombination.ts @@ -0,0 +1,147 @@ +import type { Genome, GenomeId, GeneticsConfig } from '@evo/contracts' +import type { RandomSource } from '../random/RandomSource' +import { boundGene, shortestDelta, wrapToRange } from './geneMath' +import type { GeneSpec } from './GeneSpecs' +import { + brainBlockLengths, + genomeFromParts, + genomeToVector, + sameArchitecture +} from './GenomeFactory' +import { mutateVector, mutateWeights } from './Mutation' +import type { MutationScales } from './Mutation' + +/** Builds a child genome from two parents (roadmap 4.4). */ +export interface RecombinationStrategy { + combine(mother: Genome, father: Genome, random: RandomSource): Genome +} + +/** + * Recombines two gene vectors into a new one. Per gene, in table order: + * + * - integer and boolean: uniform pick from either parent; + * - float: blend crossover (BLX-alpha): uniform in `[lo - alpha*d, hi + alpha*d]` where `d` is the + * parents' difference, then bounded. Identical parents give an identical child; + * - circular: the same blend along the shortest arc, wrapped. + * + * Each gene consumes exactly one uniform draw. + */ +export function crossoverVector( + mother: readonly number[], + father: readonly number[], + specs: readonly GeneSpec[], + random: RandomSource, + alpha: number +): number[] { + const out: number[] = [] + specs.forEach((spec, index) => { + const a = mother[index] as number + const b = father[index] as number + const u = random.float() + let candidate: number + switch (spec.kind) { + case 'int': + case 'bool': + candidate = u < 0.5 ? a : b + break + case 'circular': { + const delta = shortestDelta(a, b, spec.max - spec.min) + candidate = wrapToRange(a + delta * (u * (1 + 2 * alpha) - alpha), spec.min, spec.max) + break + } + case 'float': { + const lo = Math.min(a, b) + const difference = Math.max(a, b) - lo + const extension = alpha * difference + candidate = lo - extension + u * (difference + 2 * extension) + break + } + } + out.push(spec.kind === 'bool' ? candidate : boundGene(spec, out, candidate)) + }) + return out +} + +/** + * Recombines brain weights block by block: each block is cut at one random interior point and + * one parent supplies the prefix, the other the rest. A block therefore always holds weights from + * both parents (a one-weight block can only come from one), so both brains contribute. Consumes + * two draws per block. + */ +export function crossoverWeights( + mother: readonly number[], + father: readonly number[], + blockLengths: readonly number[], + random: RandomSource +): number[] { + let total = 0 + for (const length of blockLengths) total += length + if (mother.length !== total || father.length !== total) { + throw new RangeError('brain weight vectors must match the architecture') + } + const out: number[] = [] + let offset = 0 + for (const length of blockLengths) { + const cut = random.integer(1, Math.max(1, length - 1)) + const motherFirst = random.boolean() + const first = motherFirst ? mother : father + const second = motherFirst ? father : mother + for (let i = 0; i < length; i++) { + out.push((i < cut ? first : second)[offset + i] as number) + } + offset += length + } + return out +} + +/** + * The default strategy: crossover, then mutation of the recombined result. Uses only the random + * source it is given (the `genetics` stream) and draws in a fixed order: gene crossover, weight + * crossover, gene mutation, weight mutation. + */ +export class GenomeRecombiner implements RecombinationStrategy { + constructor( + private readonly specs: readonly GeneSpec[], + private readonly genetics: GeneticsConfig, + private readonly allocateId: () => GenomeId + ) {} + + combine(mother: Genome, father: Genome, random: RandomSource): Genome { + const architecture = mother.brain.architecture + if (!sameArchitecture(architecture, father.brain.architecture)) { + throw new RangeError('parents must share one brain architecture') + } + const scales: MutationScales = { + rate: this.genetics.mutationRateScale, + strength: this.genetics.mutationStrengthScale + } + const crossed = crossoverVector( + genomeToVector(mother), + genomeToVector(father), + this.specs, + random, + this.genetics.blendAlpha + ) + const crossedWeights = crossoverWeights( + mother.brain.weights, + father.brain.weights, + brainBlockLengths(architecture), + random + ) + const vector = mutateVector(crossed, this.specs, random, scales) + const weights = mutateWeights( + crossedWeights, + random, + { rate: this.genetics.brain.weightMutationRate, std: this.genetics.brain.weightMutationStd }, + scales + ) + const parentIds = mother.id <= father.id ? [mother.id, father.id] : [father.id, mother.id] + return genomeFromParts({ + id: this.allocateId(), + parentIds, + vector, + architecture, + weights + }) + } +} diff --git a/packages/simulation/src/genetics/geneMath.ts b/packages/simulation/src/genetics/geneMath.ts new file mode 100644 index 0000000..63769c1 --- /dev/null +++ b/packages/simulation/src/genetics/geneMath.ts @@ -0,0 +1,38 @@ +import { ORDER_MARGIN } from './GeneSpecs' +import type { GeneSpec } from './GeneSpecs' + +/** Wraps `value` into the half-open period `[min, max)`; values already inside are untouched. */ +export function wrapToRange(value: number, min: number, max: number): number { + if (value >= min && value < max) return value + const period = max - min + let remainder = (value - min) % period + if (remainder < 0) remainder += period + const wrapped = min + remainder + return wrapped >= max ? min : wrapped +} + +/** The signed shortest displacement from `from` to `to` on a circle of the given period. */ +export function shortestDelta(from: number, to: number, period: number): number { + const half = period / 2 + let delta = (to - from) % period + if (delta >= half) delta -= period + if (delta < -half) delta += period + return delta +} + +/** + * Brings a candidate value for gene `index` into its legal set: clamped into `[min, max]` (or + * wrapped, for a circular gene) and, for an ordered gene, kept strictly above the gene it must + * follow, which must already be final in `values`. If the ordering leaves no room the value is + * pinned to the upper bound, which `validateGenome` then reports. + */ +export function boundGene(spec: GeneSpec, values: readonly number[], candidate: number): number { + if (spec.kind === 'circular') return wrapToRange(candidate, spec.min, spec.max) + let lower = spec.min + if (spec.orderedAfter >= 0) { + const floor = (values[spec.orderedAfter] as number) + ORDER_MARGIN + if (floor > lower) lower = floor + } + if (candidate > spec.max) return spec.max + return candidate < lower ? Math.min(lower, spec.max) : candidate +} diff --git a/packages/simulation/src/index.ts b/packages/simulation/src/index.ts index 8408150..6e277af 100644 --- a/packages/simulation/src/index.ts +++ b/packages/simulation/src/index.ts @@ -47,3 +47,30 @@ export { SimulationClock } from './clock/SimulationClock' export type { ClockState } from './clock/SimulationClock' export { HASH_SCHEMA_VERSION, StateHasher } from './serialization/hash' export { utf8Encode } from './serialization/utf8' +export { buildGeneSpecs, ORDER_MARGIN } from './genetics/GeneSpecs' +export type { GeneSpec } from './genetics/GeneSpecs' +export { boundGene, shortestDelta, wrapToRange } from './genetics/geneMath' +export { + architectureOf, + brainBlockLengths, + brainWeightCount, + createFounderGenome, + genomeFromParts, + genomeToVector, + sameArchitecture +} from './genetics/GenomeFactory' +export type { GenomeParts } from './genetics/GenomeFactory' +export { mutateVector, mutateWeights } from './genetics/Mutation' +export type { MutationScales, WeightMutation } from './genetics/Mutation' +export { crossoverVector, crossoverWeights, GenomeRecombiner } from './genetics/Recombination' +export type { RecombinationStrategy } from './genetics/Recombination' +export { + repairGeneValues, + repairGenome, + validateGeneValues, + validateGenome +} from './genetics/GenomeValidation' +export type { GenomeValidationContext } from './genetics/GenomeValidation' +export { genomeDistance } from './genetics/GeneticDistance' +export type { GenomeDistance } from './genetics/GeneticDistance' +export { inheritedBrainWeights } from './genetics/LearningInheritance' diff --git a/packages/simulation/src/systems/ReproductionSystem.ts b/packages/simulation/src/systems/ReproductionSystem.ts index 794df03..d7ad04e 100644 --- a/packages/simulation/src/systems/ReproductionSystem.ts +++ b/packages/simulation/src/systems/ReproductionSystem.ts @@ -15,7 +15,7 @@ import type { SpatialHash } from '../spatial/SpatialHash' * 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 `reproduction.energyCost` and + * 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. */ @@ -32,8 +32,10 @@ export class ReproductionSystem { update(tick: number): void { const dt = this.config.timestep const { maturityAge, senescenceAge, maxEnergy, radius } = this.config.organisms - const { minEnergy, energyCost, offspringEnergy, cooldown, searchRadius } = + 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 } diff --git a/packages/simulation/tests/genetics/distance.test.ts b/packages/simulation/tests/genetics/distance.test.ts new file mode 100644 index 0000000..27fbee7 --- /dev/null +++ b/packages/simulation/tests/genetics/distance.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import type { Genome } from '@evo/contracts' +import { genomeDistance } from '../../src/genetics/GeneticDistance' +import { genomeFromParts, genomeToVector } from '../../src/genetics/GenomeFactory' +import { fixture, founder, genomeWith } from './helpers' + +const f = fixture() +const std = f.config.genetics.brain.initialWeightStd +const distance = (a: Genome, b: Genome) => genomeDistance(a, b, f.specs, std) + +/** `genome` with some scalar genes replaced. */ +function withGenes(genome: Genome, changes: Record): Genome { + const vector = genomeToVector(genome).map((value, index) => { + const path = f.specs[index]?.path as string + return path in changes ? (changes[path] as number) : value + }) + return genomeFromParts({ + id: genome.id, + parentIds: genome.parentIds, + vector, + architecture: genome.brain.architecture, + weights: genome.brain.weights + }) +} + +describe('genomeDistance', () => { + const a = founder(f, 0, 1) + + it('is zero between a genome and itself, and between identical copies', () => { + expect(distance(a, a).total).toBe(0) + const copy = withGenes(a, {}) + expect(distance(a, copy)).toEqual({ + total: 0, + physical: 0, + sensory: 0, + metabolic: 0, + behavioral: 0, + reproduction: 0, + brain: 0, + learning: 0 + }) + }) + + it('is symmetric', () => { + const b = withGenes(founder(f, 1, 2), { 'physical.maxSpeed': 35, 'physical.hue': 0.9 }) + expect(distance(a, b)).toEqual(distance(b, a)) + }) + + it('stays within [0, 1] for every group, even for opposite extremes', () => { + const low = genomeWith(f, 0, (spec) => spec.min, -5) + const high = genomeWith(f, 1, (spec) => (spec.kind === 'circular' ? 0.5 : spec.max), 5) + const result = distance(low, high) + for (const value of Object.values(result)) { + expect(value).toBeGreaterThanOrEqual(0) + expect(value).toBeLessThanOrEqual(1) + } + expect(result.total).toBeGreaterThan(0.9) + }) + + it('measures a physical difference as a share of the gene range', () => { + const range = f.specs.find((s) => s.path === 'physical.maxSpeed') + const b = withGenes(a, { 'physical.maxSpeed': (range?.baseline ?? 0) + 15 }) + const physicalGenes = 7 + const expected = 15 / ((range?.max ?? 0) - (range?.min ?? 0)) / physicalGenes + expect(distance(a, b).physical).toBeCloseTo(expected, 12) + expect(distance(a, b).sensory).toBe(0) + expect(distance(a, b).brain).toBe(0) + }) + + it('attributes a change to the group that owns the gene', () => { + const b = withGenes(a, { 'sensory.sensorRadius': 100 }) + const result = distance(a, b) + expect(result.sensory).toBeGreaterThan(0) + expect(result.physical).toBe(0) + expect(result.metabolic).toBe(0) + expect(result.behavioral).toBe(0) + expect(result.reproduction).toBe(0) + expect(result.learning).toBe(0) + }) + + it('measures a circular gene along the shortest arc', () => { + const near0 = withGenes(a, { 'physical.hue': 0.02 }) + const near1 = withGenes(a, { 'physical.hue': 0.98 }) + const opposite = withGenes(a, { 'physical.hue': 0.5 }) + // 0.02 and 0.98 are 0.04 apart around the circle, not 0.96 apart. + const close = distance(near0, near1).physical + const far = distance(near0, opposite).physical + expect(close).toBeLessThan(far) + expect(close).toBeCloseTo(0.08 / 7, 9) + }) + + it('measures brain difference as RMS weight difference over two initial standard deviations', () => { + const base = genomeWith(f, 0, (spec) => spec.baseline, 0) + const shifted = genomeWith(f, 1, (spec) => spec.baseline, std) + expect(distance(base, shifted).brain).toBeCloseTo(0.5, 12) + const far = genomeWith(f, 2, (spec) => spec.baseline, 10 * std) + expect(distance(base, far).brain).toBe(1) + }) + + it('takes the total as the unweighted mean of the seven groups', () => { + const b = withGenes(founder(f, 1, 2), { 'physical.maxSpeed': 35, 'metabolic.basalCost': 0.7 }) + const r = distance(a, b) + const mean = + (r.physical + + r.sensory + + r.metabolic + + r.behavioral + + r.reproduction + + r.brain + + r.learning) / + 7 + expect(r.total).toBeCloseTo(mean, 12) + }) + + it('refuses to compare genomes with different brain architectures', () => { + const other = fixture({ genetics: { brain: { hiddenSize: 4 } } }) + expect(() => distance(a, founder(other, 1, 1))).toThrow(RangeError) + }) + + it('separates a child from its parents less than it separates unrelated founders', () => { + const parent = founder(f, 0, 1) + const stranger = withGenes(founder(f, 1, 2), { + 'physical.maxSpeed': 38, + 'sensory.sensorRadius': 110 + }) + const closeRelative = withGenes(parent, { 'physical.maxSpeed': 21 }) + expect(distance(parent, closeRelative).total).toBeLessThan(distance(parent, stranger).total) + }) +}) diff --git a/packages/simulation/tests/genetics/factory.test.ts b/packages/simulation/tests/genetics/factory.test.ts new file mode 100644 index 0000000..b0bfe7d --- /dev/null +++ b/packages/simulation/tests/genetics/factory.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { GENE_DEFINITIONS } from '@evo/contracts' +import { ORDER_MARGIN } from '../../src/genetics/GeneSpecs' +import { + brainBlockLengths, + brainWeightCount, + createFounderGenome, + genomeFromParts, + genomeToVector +} from '../../src/genetics/GenomeFactory' +import { validateGenome } from '../../src/genetics/GenomeValidation' +import { fixture, founder, geneticsStream, sameState } from './helpers' + +describe('buildGeneSpecs', () => { + it('produces one spec per gene, in the canonical order', () => { + const { specs } = fixture() + expect(specs.map((spec) => spec.path)).toEqual(GENE_DEFINITIONS.map((d) => d.path)) + expect(specs).toHaveLength(23) + }) + + it('takes baselines from the config fields the genes replace', () => { + const { specs, config } = fixture({ organisms: { maxSpeed: 33 } }) + const speed = specs.find((spec) => spec.path === 'physical.maxSpeed') + expect(speed?.baseline).toBe(33) + expect(config.organisms.maxSpeed).toBe(33) + }) + + it('widens a default range to include a changed baseline', () => { + const { specs } = fixture({ organisms: { maxSpeed: 100 } }) + const speed = specs.find((spec) => spec.path === 'physical.maxSpeed') + expect(speed?.max).toBe(100) + }) + + it('honors per-gene range overrides', () => { + const { specs } = fixture({ genetics: { genes: { 'physical.maxSpeed': { max: 60 } } } }) + expect(specs.find((spec) => spec.path === 'physical.maxSpeed')?.max).toBe(60) + }) + + it('links ordered genes to the gene they must follow, by index', () => { + const { specs } = fixture() + const index = (path: string): number => specs.findIndex((spec) => spec.path === path) + expect(specs[index('physical.senescenceAge')]?.orderedAfter).toBe(index('physical.maturityAge')) + expect(specs[index('physical.maxAge')]?.orderedAfter).toBe(index('physical.senescenceAge')) + expect(specs[index('physical.radius')]?.orderedAfter).toBe(-1) + }) + + it('marks only the learning rate as relative and only hue as circular', () => { + const { specs } = fixture() + expect(specs.filter((s) => s.relative).map((s) => s.path)).toEqual(['learning.learningRate']) + expect(specs.filter((s) => s.kind === 'circular').map((s) => s.path)).toEqual(['physical.hue']) + }) + + it('uses a margin far below anything observable in seconds', () => { + expect(ORDER_MARGIN).toBeGreaterThan(0) + expect(ORDER_MARGIN).toBeLessThan(1e-3) + }) +}) + +describe('brain layout', () => { + it('splits the weights into seven blocks that add up to the default 249', () => { + const { config } = fixture() + const lengths = brainBlockLengths(config.genetics.brain) + expect(lengths).toEqual([128, 32, 8, 32, 4, 40, 5]) + expect(brainWeightCount(config.genetics.brain)).toBe(249) + }) + + it('follows a custom architecture', () => { + const architecture = { inputSize: 3, hiddenSize: 2, memorySize: 1, outputSize: 2 } + expect(brainBlockLengths(architecture)).toEqual([6, 2, 2, 2, 1, 4, 2]) + expect(brainWeightCount(architecture)).toBe(19) + }) +}) + +describe('founder genomes', () => { + it('start every scalar gene at its baseline', () => { + const f = fixture() + const genome = founder(f, 0, 1) + expect(genomeToVector(genome)).toEqual(f.specs.map((spec) => spec.baseline)) + expect(genome.version).toBe(1) + expect(genome.parentIds).toEqual([]) + }) + + it('are valid for the world they were built for', () => { + const f = fixture() + const genome = founder(f, 0, 1) + expect( + validateGenome(genome, f.specs, { brain: genome.brain.architecture, nextGenomeId: 1 }) + ).toEqual({ ok: true, errors: [] }) + }) + + it('give each founder its own random brain, reproducibly', () => { + const f = fixture() + const a = founder(f, 0, 1) + const b = founder(f, 1, 2) + expect(a.brain.weights).toHaveLength(249) + expect(a.brain.weights).not.toEqual(b.brain.weights) + expect(founder(f, 0, 1).brain.weights).toEqual(a.brain.weights) + }) + + it('draw exactly one normal per weight from the supplied stream', () => { + const f = fixture() + const used = geneticsStream(5) + createFounderGenome(0, f.specs, f.config.genetics.brain, used) + const reference = geneticsStream(5) + for (let i = 0; i < 249; i++) reference.normal(0, 1) + expect(sameState(used, reference)).toBe(true) + }) + + it('draw weights with the configured standard deviation', () => { + const f = fixture({ genetics: { brain: { initialWeightStd: 2 } } }) + const weights = founder(f, 0, 3).brain.weights + const mean = weights.reduce((sum, w) => sum + w, 0) / weights.length + const variance = weights.reduce((sum, w) => sum + (w - mean) * (w - mean), 0) / weights.length + expect(Math.sqrt(variance)).toBeGreaterThan(1.6) + expect(Math.sqrt(variance)).toBeLessThan(2.4) + }) + + it('are deeply frozen, so a lineage cannot be corrupted by accident', () => { + const genome = founder(fixture(), 0, 1) + expect(Object.isFrozen(genome)).toBe(true) + expect(Object.isFrozen(genome.physical)).toBe(true) + expect(Object.isFrozen(genome.brain)).toBe(true) + expect(Object.isFrozen(genome.brain.weights)).toBe(true) + expect(Object.isFrozen(genome.parentIds)).toBe(true) + expect(() => { + ;(genome.physical as { radius: number }).radius = 99 + }).toThrow(TypeError) + }) +}) + +describe('genome vectors', () => { + it('round-trips every gene through the vector form', () => { + const f = fixture() + const original = founder(f, 7, 1) + const vector = f.specs.map((_, index) => index + 0.5) + const rebuilt = genomeFromParts({ + id: 8, + parentIds: [3, 7], + vector, + architecture: original.brain.architecture, + weights: original.brain.weights + }) + expect(genomeToVector(rebuilt)).toEqual(vector) + expect(rebuilt.id).toBe(8) + expect(rebuilt.parentIds).toEqual([3, 7]) + }) + + it('places each gene in the group its path names', () => { + const f = fixture() + const vector = f.specs.map((spec) => spec.baseline) + const genome = genomeFromParts({ + id: 0, + parentIds: [], + vector, + architecture: founder(f, 0, 1).brain.architecture, + weights: founder(f, 0, 1).brain.weights + }) + expect(genome.physical.maxSpeed).toBe(f.config.organisms.maxSpeed) + expect(genome.sensory.sensorRadius).toBe(f.config.organisms.sensorRadius) + expect(genome.metabolic.basalCost).toBe(f.config.organisms.basalCost) + expect(genome.behavioral.wanderJitter).toBe(f.config.organisms.wanderJitter) + expect(genome.reproduction.offspringInvestment).toBe(f.config.reproduction.offspringInvestment) + expect(genome.learning.discountFactor).toBe(f.config.genetics.learning.discountFactor) + }) + + it('copies its inputs, so later changes to them cannot reach the genome', () => { + const f = fixture() + const vector = f.specs.map((spec) => spec.baseline) + const weights = founder(f, 0, 1).brain.weights.slice() + const genome = genomeFromParts({ + id: 0, + parentIds: [], + vector, + architecture: founder(f, 0, 1).brain.architecture, + weights + }) + vector[0] = -1 + weights[0] = -1 + expect(genome.physical.radius).toBe(f.config.organisms.radius) + expect(genome.brain.weights[0]).not.toBe(-1) + }) +}) diff --git a/packages/simulation/tests/genetics/helpers.ts b/packages/simulation/tests/genetics/helpers.ts new file mode 100644 index 0000000..649f4d7 --- /dev/null +++ b/packages/simulation/tests/genetics/helpers.ts @@ -0,0 +1,63 @@ +import { resolveWorldConfig } from '@evo/config' +import type { Genome, GenomeId, WorldConfig, WorldConfigOverrides } from '@evo/contracts' +import { buildGeneSpecs } from '../../src/genetics/GeneSpecs' +import type { GeneSpec } from '../../src/genetics/GeneSpecs' +import { createFounderGenome, genomeFromParts } from '../../src/genetics/GenomeFactory' +import { SeededRandom } from '../../src/random/SeededRandom' +import { deriveStreamWords } from '../../src/random/streams' + +/** A seeded `genetics` stream. */ +export function geneticsStream(seed: number): SeededRandom { + return new SeededRandom(deriveStreamWords(seed, 'genetics')) +} + +export interface Fixture { + config: WorldConfig + specs: GeneSpec[] +} + +export function fixture(overrides?: WorldConfigOverrides): Fixture { + const config = resolveWorldConfig(overrides) + return { config, specs: buildGeneSpecs(config) } +} + +export function founder(f: Fixture, id: GenomeId, seed: number): Genome { + return createFounderGenome(id, f.specs, f.config.genetics.brain, geneticsStream(seed)) +} + +/** A genome whose every scalar gene sits at `pick(spec)` and whose weights are all `weight`. */ +export function genomeWith( + f: Fixture, + id: GenomeId, + pick: (spec: GeneSpec) => number, + weight: number, + parentIds: readonly GenomeId[] = [] +): Genome { + const base = founder(f, id, 1) + return genomeFromParts({ + id, + parentIds, + vector: f.specs.map(pick), + architecture: base.brain.architecture, + weights: base.brain.weights.map(() => weight) + }) +} + +/** A same-seed pair of streams that have consumed the same number of draws is in the same state. */ +export function sameState(a: SeededRandom, b: SeededRandom): boolean { + return JSON.stringify(a.getState()) === JSON.stringify(b.getState()) +} + +/** Builds a synthetic spec table (the production table has no integer, boolean or correlated gene). */ +export function synthetic( + rows: ReadonlyArray & Pick> +): GeneSpec[] { + return rows.map((row) => ({ + mutationRate: 1, + mutationStd: 0.1, + baseline: (row.min + row.max) / 2, + relative: false, + orderedAfter: -1, + ...row + })) +} diff --git a/packages/simulation/tests/genetics/learning-inheritance.test.ts b/packages/simulation/tests/genetics/learning-inheritance.test.ts new file mode 100644 index 0000000..2efeeec --- /dev/null +++ b/packages/simulation/tests/genetics/learning-inheritance.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { inheritedBrainWeights } from '../../src/genetics/LearningInheritance' + +const genome = [0, 1, 2, 3] +const learned = [4, 1, 0, 11] + +describe('inheritedBrainWeights', () => { + it('baldwinian: learning is never inherited - the child starts from the genome weights', () => { + expect(inheritedBrainWeights(genome, learned, 'baldwinian', 0.15)).toEqual(genome) + expect(inheritedBrainWeights(genome, learned, 'baldwinian', 1)).toEqual(genome) + }) + + it('lamarckian: everything learned is inherited - the child starts from the learned weights', () => { + expect(inheritedBrainWeights(genome, learned, 'lamarckian', 0)).toEqual(learned) + expect(inheritedBrainWeights(genome, learned, 'lamarckian', 0.15)).toEqual(learned) + }) + + it('partial: the child inherits the configured fraction of what was learned', () => { + // genome + fraction * (learned - genome) = [0 + .5*4, 1, 2 + .5*-2, 3 + .5*8] + expect(inheritedBrainWeights(genome, learned, 'partial', 0.5)).toEqual([2, 1, 1, 7]) + }) + + it('partial at the default fraction of 0.15', () => { + const result = inheritedBrainWeights(genome, learned, 'partial', 0.15) + expect(result[0]).toBeCloseTo(0.6, 12) + expect(result[1]).toBe(1) + expect(result[2]).toBeCloseTo(1.7, 12) + expect(result[3]).toBeCloseTo(4.2, 12) + }) + + it('partial reduces to the other two modes at the ends of the fraction range', () => { + expect(inheritedBrainWeights(genome, learned, 'partial', 0)).toEqual(genome) + expect(inheritedBrainWeights(genome, learned, 'partial', 1)).toEqual(learned) + }) + + it('partial lands between the genome and learned weights', () => { + const result = inheritedBrainWeights(genome, learned, 'partial', 0.3) + result.forEach((value, i) => { + const low = Math.min(genome[i] as number, learned[i] as number) + const high = Math.max(genome[i] as number, learned[i] as number) + expect(value).toBeGreaterThanOrEqual(low) + expect(value).toBeLessThanOrEqual(high) + }) + }) + + it('inherits nothing different when nothing was learned, in every mode', () => { + for (const mode of ['baldwinian', 'partial', 'lamarckian'] as const) { + expect(inheritedBrainWeights(genome, genome, mode, 0.15)).toEqual(genome) + } + }) + + it('returns a new array and never modifies its inputs', () => { + const g = genome.slice() + const l = learned.slice() + const out = inheritedBrainWeights(g, l, 'baldwinian', 0.15) + out[0] = 99 + expect(g).toEqual(genome) + const lamarckian = inheritedBrainWeights(g, l, 'lamarckian', 0.15) + lamarckian[0] = 99 + expect(l).toEqual(learned) + }) + + it('rejects weight vectors of different lengths', () => { + expect(() => inheritedBrainWeights([1, 2], [1], 'partial', 0.5)).toThrow(RangeError) + }) + + it('rejects a partial fraction outside [0, 1]', () => { + expect(() => inheritedBrainWeights(genome, learned, 'partial', 1.1)).toThrow(RangeError) + expect(() => inheritedBrainWeights(genome, learned, 'partial', -0.1)).toThrow(RangeError) + }) +}) diff --git a/packages/simulation/tests/genetics/mutation.test.ts b/packages/simulation/tests/genetics/mutation.test.ts new file mode 100644 index 0000000..329d7d8 --- /dev/null +++ b/packages/simulation/tests/genetics/mutation.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from 'vitest' +import { mutateVector, mutateWeights } from '../../src/genetics/Mutation' +import { genomeToVector } from '../../src/genetics/GenomeFactory' +import { validateGeneValues } from '../../src/genetics/GenomeValidation' +import { fixture, founder, geneticsStream, sameState, synthetic } from './helpers' + +const FULL = { rate: 1, strength: 1 } +const OFF = { rate: 0, strength: 0 } + +describe('mutateVector', () => { + it('never changes the vector it was given', () => { + const f = fixture() + const input = genomeToVector(founder(f, 0, 1)) + const copy = input.slice() + mutateVector(input, f.specs, geneticsStream(1), { rate: 10, strength: 5 }) + expect(input).toEqual(copy) + }) + + it('leaves everything unchanged when mutation is disabled', () => { + const f = fixture() + const input = genomeToVector(founder(f, 0, 1)) + expect(mutateVector(input, f.specs, geneticsStream(1), OFF)).toEqual(input) + }) + + it('changes genes when mutation is on, and only by bounded steps', () => { + const f = fixture() + const input = genomeToVector(founder(f, 0, 1)) + const out = mutateVector(input, f.specs, geneticsStream(2), { rate: 10, strength: 1 }) + expect(out).not.toEqual(input) + expect(validateGeneValues(out, f.specs)).toEqual([]) + }) + + it('keeps every gene inside its range, however hard it is pushed', () => { + const f = fixture() + for (let seed = 0; seed < 300; seed++) { + const input = genomeToVector(founder(f, 0, 1)) + const out = mutateVector(input, f.specs, geneticsStream(seed), { rate: 10, strength: 50 }) + expect(validateGeneValues(out, f.specs)).toEqual([]) + } + }) + + it('keeps the life-stage ages strictly ordered through repeated mutation', () => { + const f = fixture() + let vector = genomeToVector(founder(f, 0, 1)) + const random = geneticsStream(9) + const index = (path: string): number => f.specs.findIndex((spec) => spec.path === path) + for (let generation = 0; generation < 500; generation++) { + vector = mutateVector(vector, f.specs, random, { rate: 10, strength: 20 }) + expect(vector[index('physical.senescenceAge')]).toBeGreaterThan( + vector[index('physical.maturityAge')] as number + ) + expect(vector[index('physical.maxAge')]).toBeGreaterThan( + vector[index('physical.senescenceAge')] as number + ) + } + }) + + it('re-orders an ordered gene that did not mutate when the gene before it moved above it', () => { + const specs = synthetic([ + { path: 'a.first', kind: 'float', min: 0, max: 20, mutationRate: 1, mutationStd: 0.1 }, + { + path: 'a.second', + kind: 'float', + min: 0, + max: 30, + mutationRate: 0, + orderedAfter: 0 + } + ]) + let sawRaise = false + for (let seed = 0; seed < 200; seed++) { + const out = mutateVector([3, 4], specs, geneticsStream(seed), FULL) + expect(out[1]).toBeGreaterThan(out[0] as number) + if ((out[1] as number) !== 4) sawRaise = true + } + expect(sawRaise).toBe(true) + }) + + it('draws the same amount from the stream whatever the rates are', () => { + const f = fixture() + const input = genomeToVector(founder(f, 0, 1)) + const a = geneticsStream(4) + const b = geneticsStream(4) + mutateVector(input, f.specs, a, OFF) + mutateVector(input, f.specs, b, { rate: 10, strength: 3 }) + expect(sameState(a, b)).toBe(true) + }) + + describe('operators by gene kind (synthetic table)', () => { + const specs = synthetic([ + { path: 'g.float', kind: 'float', min: 0, max: 10, baseline: 5 }, + { path: 'g.int', kind: 'int', min: 0, max: 5, baseline: 2 }, + { path: 'g.bool', kind: 'bool', min: 0, max: 1, baseline: 0 }, + { path: 'g.hue', kind: 'circular', min: 0, max: 1, baseline: 0.5 } + ]) + + it('keeps integer genes integral and inside their range', () => { + for (let seed = 0; seed < 200; seed++) { + const out = mutateVector([5, 2, 0, 0.5], specs, geneticsStream(seed), FULL) + expect(Number.isInteger(out[1])).toBe(true) + expect(out[1]).toBeGreaterThanOrEqual(0) + expect(out[1]).toBeLessThanOrEqual(5) + } + }) + + it('moves an integer gene by exactly one step, or holds it at a bound', () => { + for (let seed = 0; seed < 100; seed++) { + const middle = mutateVector([5, 2, 0, 0.5], specs, geneticsStream(seed), FULL) + expect(Math.abs((middle[1] as number) - 2)).toBe(1) + const atBound = mutateVector([5, 5, 0, 0.5], specs, geneticsStream(seed), FULL) + expect([4, 5]).toContain(atBound[1]) + } + }) + + it('flips a boolean gene when it mutates and leaves it otherwise', () => { + expect(mutateVector([5, 2, 0, 0.5], specs, geneticsStream(1), FULL)[2]).toBe(1) + expect(mutateVector([5, 2, 1, 0.5], specs, geneticsStream(1), FULL)[2]).toBe(0) + expect(mutateVector([5, 2, 1, 0.5], specs, geneticsStream(1), OFF)[2]).toBe(1) + }) + + it('wraps a circular gene around its period instead of clamping it', () => { + let wrapped = false + for (let seed = 0; seed < 300; seed++) { + const out = mutateVector([5, 2, 0, 0.98], specs, geneticsStream(seed), { + rate: 1, + strength: 3 + }) + expect(out[3]).toBeGreaterThanOrEqual(0) + expect(out[3]).toBeLessThan(1) + if ((out[3] as number) < 0.5) wrapped = true + } + expect(wrapped).toBe(true) + }) + + it('scales a float step by the range width', () => { + const wide = synthetic([ + { path: 'g.float', kind: 'float', min: 0, max: 1000, baseline: 500, mutationStd: 0.01 } + ]) + const narrow = synthetic([ + { path: 'g.float', kind: 'float', min: 0, max: 10, baseline: 5, mutationStd: 0.01 } + ]) + const wideStep = Math.abs( + (mutateVector([500], wide, geneticsStream(3), FULL)[0] as number) - 500 + ) + const narrowStep = Math.abs( + (mutateVector([5], narrow, geneticsStream(3), FULL)[0] as number) - 5 + ) + expect(wideStep / narrowStep).toBeCloseTo(100, 6) + }) + + it('scales a relative gene by its current value instead of its range', () => { + const relative = synthetic([ + { + path: 'g.rate', + kind: 'float', + min: 0.001, + max: 1000, + baseline: 1, + mutationStd: 0.1, + relative: true + } + ]) + const small = (mutateVector([1], relative, geneticsStream(3), FULL)[0] as number) - 1 + const large = (mutateVector([10], relative, geneticsStream(3), FULL)[0] as number) - 10 + expect(large / small).toBeCloseTo(10, 6) + }) + + it('moves correlated genes together by one shared draw, each scaled to its own range', () => { + const correlated = synthetic([ + { + path: 'size.small', + kind: 'float', + min: 0, + max: 10, + baseline: 5, + mutationStd: 0.01, + correlationGroup: 'size' + }, + { + path: 'size.large', + kind: 'float', + min: 0, + max: 100, + baseline: 50, + mutationStd: 0.01, + correlationGroup: 'size' + } + ]) + for (let seed = 0; seed < 50; seed++) { + const out = mutateVector([5, 50], correlated, geneticsStream(seed), FULL) + const smallShift = ((out[0] as number) - 5) / 10 + const largeShift = ((out[1] as number) - 50) / 100 + expect(smallShift).toBeCloseTo(largeShift, 9) + expect(smallShift).not.toBe(0) + } + }) + + it('mutates correlated genes together or not at all', () => { + const correlated = synthetic([ + { + path: 'size.a', + kind: 'float', + min: 0, + max: 10, + mutationRate: 0.5, + correlationGroup: 'size' + }, + { + path: 'size.b', + kind: 'float', + min: 0, + max: 10, + mutationRate: 0.5, + correlationGroup: 'size' + } + ]) + for (let seed = 0; seed < 100; seed++) { + const out = mutateVector([5, 5], correlated, geneticsStream(seed), FULL) + expect(out[0] === 5).toBe(out[1] === 5) + } + }) + }) +}) + +describe('mutateWeights', () => { + const weights = Array.from({ length: 200 }, (_, i) => i / 100) + + it('never changes the array it was given', () => { + const copy = weights.slice() + mutateWeights(weights, geneticsStream(1), { rate: 1, std: 1 }, FULL) + expect(weights).toEqual(copy) + }) + + it('preserves the length, so the architecture is preserved', () => { + expect(mutateWeights(weights, geneticsStream(1), { rate: 1, std: 1 }, FULL)).toHaveLength(200) + }) + + it('leaves every weight alone at rate zero or strength zero', () => { + expect(mutateWeights(weights, geneticsStream(1), { rate: 0, std: 1 }, FULL)).toEqual(weights) + expect(mutateWeights(weights, geneticsStream(1), { rate: 1, std: 1 }, OFF)).toEqual(weights) + }) + + it('mutates roughly the configured share of weights', () => { + const out = mutateWeights(weights, geneticsStream(5), { rate: 0.25, std: 1 }, FULL) + const changed = out.filter((value, i) => value !== weights[i]).length + expect(changed).toBeGreaterThan(25) + expect(changed).toBeLessThan(75) + }) + + it('applies the global rate and strength scales', () => { + const baseline = mutateWeights(weights, geneticsStream(6), { rate: 0.1, std: 0.1 }, FULL) + const boosted = mutateWeights( + weights, + geneticsStream(6), + { rate: 0.1, std: 0.1 }, + { + rate: 10, + strength: 1 + } + ) + const count = (out: number[]): number => out.filter((v, i) => v !== weights[i]).length + expect(count(boosted)).toBeGreaterThan(count(baseline)) + }) + + it('draws the same amount from the stream whatever the rate is', () => { + const a = geneticsStream(8) + const b = geneticsStream(8) + mutateWeights(weights, a, { rate: 0, std: 1 }, FULL) + mutateWeights(weights, b, { rate: 1, std: 1 }, FULL) + expect(sameState(a, b)).toBe(true) + }) +}) diff --git a/packages/simulation/tests/genetics/recombination.test.ts b/packages/simulation/tests/genetics/recombination.test.ts new file mode 100644 index 0000000..8533a5c --- /dev/null +++ b/packages/simulation/tests/genetics/recombination.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it } from 'vitest' +import type { WorldConfigOverrides } from '@evo/contracts' +import { + crossoverVector, + crossoverWeights, + GenomeRecombiner +} from '../../src/genetics/Recombination' +import { brainBlockLengths, genomeToVector } from '../../src/genetics/GenomeFactory' +import { validateGenome } from '../../src/genetics/GenomeValidation' +import { fixture, founder, genomeWith, geneticsStream, sameState, synthetic } from './helpers' + +describe('crossoverVector', () => { + const specs = synthetic([ + { path: 'g.float', kind: 'float', min: 0, max: 100 }, + { path: 'g.int', kind: 'int', min: 0, max: 10 }, + { path: 'g.bool', kind: 'bool', min: 0, max: 1 }, + { path: 'g.hue', kind: 'circular', min: 0, max: 1 } + ]) + + it('returns the shared value when both parents are identical', () => { + const parent = [37, 4, 1, 0.3] + for (let seed = 0; seed < 50; seed++) { + expect(crossoverVector(parent, parent, specs, geneticsStream(seed), 0.25)).toEqual(parent) + } + }) + + it('draws a continuous gene from the blend interval around the parents', () => { + const lowest = 20 + const highest = 60 + const alpha = 0.25 + let belowLow = false + let aboveHigh = false + for (let seed = 0; seed < 400; seed++) { + const child = crossoverVector( + [lowest, 0, 0, 0], + [highest, 0, 0, 0], + specs, + geneticsStream(seed), + alpha + ) + const value = child[0] as number + expect(value).toBeGreaterThanOrEqual(lowest - alpha * 40) + expect(value).toBeLessThanOrEqual(highest + alpha * 40) + if (value < lowest) belowLow = true + if (value > highest) aboveHigh = true + } + expect(belowLow).toBe(true) + expect(aboveHigh).toBe(true) + }) + + it('stays strictly between the parents when alpha is zero', () => { + for (let seed = 0; seed < 100; seed++) { + const child = crossoverVector([20, 0, 0, 0], [60, 0, 0, 0], specs, geneticsStream(seed), 0) + expect(child[0]).toBeGreaterThanOrEqual(20) + expect(child[0]).toBeLessThanOrEqual(60) + } + }) + + it('clamps an extended blend to the gene range', () => { + for (let seed = 0; seed < 200; seed++) { + const child = crossoverVector([0, 0, 0, 0], [100, 0, 0, 0], specs, geneticsStream(seed), 2) + expect(child[0]).toBeGreaterThanOrEqual(0) + expect(child[0]).toBeLessThanOrEqual(100) + } + }) + + it('inherits integer and boolean genes whole from one parent or the other', () => { + const seen = { ints: new Set(), bools: new Set() } + for (let seed = 0; seed < 100; seed++) { + const child = crossoverVector([0, 3, 0, 0], [0, 8, 1, 0], specs, geneticsStream(seed), 0.25) + expect([3, 8]).toContain(child[1]) + expect([0, 1]).toContain(child[2]) + seen.ints.add(child[1] as number) + seen.bools.add(child[2] as number) + } + expect(seen.ints.size).toBe(2) + expect(seen.bools.size).toBe(2) + }) + + it('keeps integer genes integral', () => { + for (let seed = 0; seed < 100; seed++) { + const child = crossoverVector([0, 3, 0, 0], [0, 8, 0, 0], specs, geneticsStream(seed), 0.25) + expect(Number.isInteger(child[1])).toBe(true) + } + }) + + it('blends a circular gene along the shortest arc', () => { + for (let seed = 0; seed < 200; seed++) { + const child = crossoverVector( + [0, 0, 0, 0.95], + [0, 0, 0, 0.05], + specs, + geneticsStream(seed), + 0 + ) + const hue = child[3] as number + // The short way from 0.95 to 0.05 crosses 0, so the child is near 0/1, never near 0.5. + const distanceFromZero = Math.min(hue, 1 - hue) + expect(distanceFromZero).toBeLessThanOrEqual(0.05 + 1e-9) + } + }) + + it('consumes exactly one draw per gene', () => { + const used = geneticsStream(3) + crossoverVector([1, 1, 1, 0.1], [2, 2, 0, 0.2], specs, used, 0.25) + const reference = geneticsStream(3) + for (let i = 0; i < 4; i++) reference.float() + expect(sameState(used, reference)).toBe(true) + }) + + it('keeps ordered genes in order', () => { + const ordered = synthetic([ + { path: 'a.first', kind: 'float', min: 0, max: 100 }, + { path: 'a.second', kind: 'float', min: 0, max: 200, orderedAfter: 0 } + ]) + for (let seed = 0; seed < 300; seed++) { + const child = crossoverVector([80, 85], [20, 25], ordered, geneticsStream(seed), 1) + expect(child[1]).toBeGreaterThan(child[0] as number) + } + }) +}) + +describe('crossoverWeights', () => { + const blocks = [10, 6, 1, 8] + const mother = Array.from({ length: 25 }, () => 0) + const father = Array.from({ length: 25 }, () => 1) + + it('takes weights from both parents in every block that can hold both', () => { + for (let seed = 0; seed < 100; seed++) { + const child = crossoverWeights(mother, father, blocks, geneticsStream(seed)) + let offset = 0 + for (const length of blocks) { + const slice = child.slice(offset, offset + length) + if (length >= 2) { + expect(slice).toContain(0) + expect(slice).toContain(1) + } + offset += length + } + } + }) + + it('is a single cut per block: one parent supplies a prefix, the other the remainder', () => { + const child = crossoverWeights( + mother.slice(0, 12), + father.slice(0, 12), + [12], + geneticsStream(4) + ) + const changes = child.filter((value, i) => i > 0 && value !== child[i - 1]).length + expect(changes).toBe(1) + }) + + it('draws the cut and the starting parent at random', () => { + const starts = new Set() + const cuts = new Set() + for (let seed = 0; seed < 200; seed++) { + const child = crossoverWeights( + mother.slice(0, 12), + father.slice(0, 12), + [12], + geneticsStream(seed) + ) + starts.add(child[0] as number) + cuts.add(child.filter((value) => value === child[0]).length) + } + expect(starts.size).toBe(2) + expect(cuts.size).toBeGreaterThan(3) + }) + + it('preserves the vector length', () => { + expect(crossoverWeights(mother, father, blocks, geneticsStream(1))).toHaveLength(25) + }) + + it('rejects vectors that do not match the architecture', () => { + expect(() => crossoverWeights(mother, father.slice(1), blocks, geneticsStream(1))).toThrow( + RangeError + ) + expect(() => crossoverWeights(mother, father, [5, 5], geneticsStream(1))).toThrow(RangeError) + }) + + it('handles the default architecture block by block', () => { + const f = fixture() + const lengths = brainBlockLengths(f.config.genetics.brain) + const a = founder(f, 0, 1).brain.weights + const b = founder(f, 1, 2).brain.weights + const child = crossoverWeights(a, b, lengths, geneticsStream(1)) + expect(child).toHaveLength(249) + expect(child.every((value, i) => value === a[i] || value === b[i])).toBe(true) + }) +}) + +describe('GenomeRecombiner', () => { + function recombiner(overrides: WorldConfigOverrides = {}) { + const f = fixture(overrides) + let next = 100 + return { f, recombiner: new GenomeRecombiner(f.specs, f.config.genetics, () => next++) } + } + + it('gives the child a fresh id and its parents ids in ascending order', () => { + const { f, recombiner: r } = recombiner() + const low = founder(f, 3, 1) + const high = founder(f, 9, 2) + const child = r.combine(low, high, geneticsStream(1)) + expect(child.id).toBe(100) + expect(child.parentIds).toEqual([3, 9]) + expect(r.combine(high, low, geneticsStream(1)).parentIds).toEqual([3, 9]) + }) + + it('allocates a new id for every child', () => { + const { f, recombiner: r } = recombiner() + const a = founder(f, 0, 1) + const b = founder(f, 1, 2) + const ids = [1, 2, 3].map(() => r.combine(a, b, geneticsStream(1)).id) + expect(ids).toEqual([100, 101, 102]) + }) + + it('produces valid genomes however hard mutation is pushed', () => { + const { f, recombiner: r } = recombiner({ + genetics: { mutationRateScale: 10, mutationStrengthScale: 30 } + }) + const mother = founder(f, 0, 1) + const father = founder(f, 1, 2) + for (let seed = 0; seed < 100; seed++) { + const child = r.combine(mother, father, geneticsStream(seed)) + expect( + validateGenome(child, f.specs, { brain: child.brain.architecture, nextGenomeId: 1000 }) + ).toEqual({ ok: true, errors: [] }) + } + }) + + it('never modifies its parents', () => { + const { f, recombiner: r } = recombiner({ genetics: { mutationRateScale: 10 } }) + const mother = founder(f, 0, 1) + const father = founder(f, 1, 2) + const before = JSON.stringify([mother, father]) + r.combine(mother, father, geneticsStream(1)) + expect(JSON.stringify([mother, father])).toBe(before) + }) + + it('passes both parents scalar genes on when mutation is off', () => { + const { f, recombiner: r } = recombiner({ + genetics: { mutationRateScale: 0, blendAlpha: 0 } + }) + const mother = genomeWith(f, 0, (spec) => spec.min, 0.1) + const father = genomeWith(f, 1, (spec) => spec.min + (spec.max - spec.min) / 2, 0.9) + const firstGene = new Set() + for (let seed = 0; seed < 100; seed++) { + const child = genomeToVector(r.combine(mother, father, geneticsStream(seed))) + const motherVector = genomeToVector(mother) + const fatherVector = genomeToVector(father) + child.forEach((value, i) => { + // Circular genes blend along the shortest arc, which can leave the linear interval. + if (f.specs[i]?.kind === 'circular') return + expect(value).toBeGreaterThanOrEqual( + Math.min(motherVector[i] as number, fatherVector[i] as number) + ) + expect(value).toBeLessThanOrEqual( + Math.max(motherVector[i] as number, fatherVector[i] as number) + ) + }) + firstGene.add(child[0] as number) + } + expect(firstGene.size).toBeGreaterThan(50) + }) + + it('takes brain weights from both parents', () => { + const { f, recombiner: r } = recombiner({ genetics: { mutationRateScale: 0 } }) + const mother = genomeWith(f, 0, (spec) => spec.baseline, 0) + const father = genomeWith(f, 1, (spec) => spec.baseline, 1) + for (let seed = 0; seed < 30; seed++) { + const weights = r.combine(mother, father, geneticsStream(seed)).brain.weights + expect(weights).toContain(0) + expect(weights).toContain(1) + expect(weights.every((w) => w === 0 || w === 1)).toBe(true) + } + }) + + it('preserves the brain architecture and weight count', () => { + const { f, recombiner: r } = recombiner() + const mother = founder(f, 0, 1) + const child = r.combine(mother, founder(f, 1, 2), geneticsStream(1)) + expect(child.brain.architecture).toEqual(mother.brain.architecture) + expect(child.brain.weights).toHaveLength(mother.brain.weights.length) + }) + + it('produces a child that differs from both parents when mutation is on', () => { + const { f, recombiner: r } = recombiner({ genetics: { mutationRateScale: 10 } }) + const mother = founder(f, 0, 1) + const father = founder(f, 1, 2) + const child = r.combine(mother, father, geneticsStream(1)) + expect(genomeToVector(child)).not.toEqual(genomeToVector(mother)) + expect(child.brain.weights).not.toEqual(mother.brain.weights) + expect(child.brain.weights).not.toEqual(father.brain.weights) + }) + + it('reproduces the same child from the same stream', () => { + const { f, recombiner: r } = recombiner() + const mother = founder(f, 0, 1) + const father = founder(f, 1, 2) + const a = r.combine(mother, father, geneticsStream(7)) + const b = r.combine(mother, father, geneticsStream(7)) + expect({ ...a, id: 0 }).toEqual({ ...b, id: 0 }) + }) + + it('uses a fixed number of draws per birth, whatever the mutation settings', () => { + const off = recombiner({ genetics: { mutationRateScale: 0, mutationStrengthScale: 0 } }) + const on = recombiner({ genetics: { mutationRateScale: 10, mutationStrengthScale: 5 } }) + const streamOff = geneticsStream(2) + const streamOn = geneticsStream(2) + off.recombiner.combine(founder(off.f, 0, 1), founder(off.f, 1, 2), streamOff) + on.recombiner.combine(founder(on.f, 0, 1), founder(on.f, 1, 2), streamOn) + expect(sameState(streamOff, streamOn)).toBe(true) + }) + + it('refuses parents with different brain architectures', () => { + const { f, recombiner: r } = recombiner() + const other = fixture({ genetics: { brain: { hiddenSize: 4 } } }) + expect(() => r.combine(founder(f, 0, 1), founder(other, 1, 1), geneticsStream(1))).toThrow( + RangeError + ) + }) +}) diff --git a/packages/simulation/tests/genetics/validation.test.ts b/packages/simulation/tests/genetics/validation.test.ts new file mode 100644 index 0000000..eac66de --- /dev/null +++ b/packages/simulation/tests/genetics/validation.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest' +import type { Genome } from '@evo/contracts' +import { genomeFromParts, genomeToVector } from '../../src/genetics/GenomeFactory' +import { + repairGeneValues, + repairGenome, + validateGeneValues, + validateGenome +} from '../../src/genetics/GenomeValidation' +import { fixture, founder, synthetic } from './helpers' + +const f = fixture() + +function context(genome: Genome, nextGenomeId = 1000) { + return { brain: genome.brain.architecture, nextGenomeId } +} + +/** Rebuilds a genome with some scalar genes replaced, bypassing the operators' bounds. */ +function withGenes(genome: Genome, changes: Record, weights?: number[]): Genome { + const vector = genomeToVector(genome).map((value, index) => { + const path = f.specs[index]?.path as string + return path in changes ? (changes[path] as number) : value + }) + return genomeFromParts({ + id: genome.id, + parentIds: genome.parentIds, + vector, + architecture: genome.brain.architecture, + weights: weights ?? genome.brain.weights + }) +} + +describe('validateGenome', () => { + const valid = founder(f, 5, 1) + + it('accepts a valid genome', () => { + expect(validateGenome(valid, f.specs, context(valid))).toEqual({ ok: true, errors: [] }) + }) + + it.each([ + ['physical.maxSpeed', 1000, 'physical.maxSpeed (1000) is outside its range [10, 40]'], + ['physical.radius', -1, 'physical.radius (-1) is outside its range [2.5, 10]'], + ['sensory.sensorRadius', 5, 'sensory.sensorRadius (5) is outside its range [30, 120]'], + ['physical.hue', 1, 'physical.hue (1) is outside its range [0, 1]'] + ])('rejects %s out of range', (path, value, message) => { + const result = validateGenome(withGenes(valid, { [path]: value }), f.specs, context(valid)) + expect(result.ok).toBe(false) + expect(result.errors).toContain(message) + }) + + it('rejects non-finite genes', () => { + for (const bad of [Number.NaN, Infinity, -Infinity]) { + const result = validateGenome( + withGenes(valid, { 'physical.maxSpeed': bad }), + f.specs, + context(valid) + ) + expect(result.errors).toContain('physical.maxSpeed is not a finite number') + } + }) + + it('rejects life-stage ages that are not strictly ordered', () => { + const tied = withGenes(valid, { 'physical.senescenceAge': 120, 'physical.maturityAge': 120 }) + const result = validateGenome(tied, f.specs, context(valid)) + expect(result.errors).toContain( + 'physical.senescenceAge must be greater than physical.maturityAge' + ) + }) + + it('rejects a negative learning rate, a discount factor outside [0, 1] and zero investment', () => { + const bad = withGenes(valid, { + 'learning.learningRate': -0.1, + 'learning.discountFactor': 1.5, + 'reproduction.offspringInvestment': 0 + }) + const errors = validateGenome(bad, f.specs, context(valid)).errors + expect(errors).toContain('learningRate must be nonnegative') + expect(errors).toContain('discountFactor must be between zero and one') + expect(errors).toContain('offspringInvestment must be positive') + }) + + it('rejects a brain whose weight count does not match its architecture', () => { + const short = withGenes(valid, {}, valid.brain.weights.slice(1)) + expect(validateGenome(short, f.specs, context(valid)).errors).toContain( + 'brain has 248 weights but its architecture needs 249' + ) + }) + + it('rejects non-finite brain weights', () => { + const weights = valid.brain.weights.slice() + weights[3] = Number.NaN + expect(validateGenome(withGenes(valid, {}, weights), f.specs, context(valid)).errors).toContain( + 'brain weights must all be finite numbers' + ) + }) + + it('rejects a brain architecture different from the world configuration', () => { + const result = validateGenome(valid, f.specs, { + brain: { inputSize: 1, hiddenSize: 1, memorySize: 1, outputSize: 1 } + }) + expect(result.errors).toContain('brain architecture does not match the world configuration') + }) + + it('rejects a genome id that has not been allocated, and bad parent ids', () => { + expect(validateGenome(valid, f.specs, context(valid, 5)).errors).toContain( + 'genome id (5) has not been allocated yet' + ) + const child = genomeFromParts({ + id: 5, + parentIds: [9, 2], + vector: genomeToVector(valid), + architecture: valid.brain.architecture, + weights: valid.brain.weights + }) + const errors = validateGenome(child, f.specs, context(child)).errors + expect(errors).toContain('parent id (9) must be a nonnegative integer below the genome id') + expect(errors).toContain('parent ids must be strictly ascending') + }) + + it('reports every violation at once', () => { + const bad = withGenes(valid, { 'physical.maxSpeed': 1000, 'sensory.sensorRadius': 5 }) + expect(validateGenome(bad, f.specs, context(valid)).errors.length).toBeGreaterThanOrEqual(2) + }) + + it('does not modify the genome it checks', () => { + const before = JSON.stringify(valid) + validateGenome(valid, f.specs, context(valid)) + expect(JSON.stringify(valid)).toBe(before) + }) +}) + +describe('validateGeneValues (synthetic table)', () => { + const specs = synthetic([ + { path: 'g.float', kind: 'float', min: 0, max: 10 }, + { path: 'g.int', kind: 'int', min: 0, max: 5 }, + { path: 'g.bool', kind: 'bool', min: 0, max: 1 }, + { path: 'g.hue', kind: 'circular', min: 0, max: 1 } + ]) + + it('accepts legal values', () => { + expect(validateGeneValues([5, 3, 1, 0.5], specs)).toEqual([]) + }) + + it('rejects a non-integer integer gene', () => { + expect(validateGeneValues([5, 2.5, 0, 0.5], specs)).toContain('g.int must be an integer') + }) + + it('rejects a boolean gene that is not 0 or 1', () => { + expect(validateGeneValues([5, 2, 0.5, 0.5], specs)).toContain('g.bool must be 0 or 1') + }) + + it('treats a circular gene as half-open: its upper bound is outside', () => { + expect(validateGeneValues([5, 2, 0, 1], specs)).toContain( + 'g.hue (1) is outside its range [0, 1]' + ) + expect(validateGeneValues([5, 2, 0, 0], specs)).toEqual([]) + }) + + it('rejects the wrong number of genes', () => { + expect(validateGeneValues([5, 2], specs)).toEqual(['expected 4 genes but found 2']) + }) +}) + +describe('repairGenome', () => { + const valid = founder(f, 5, 1) + + it('returns an equal genome when nothing is wrong', () => { + expect(repairGenome(valid, f.specs)).toEqual(valid) + }) + + it('clamps out-of-range genes and reports a valid result', () => { + const broken = withGenes(valid, { 'physical.maxSpeed': 1000, 'sensory.sensorRadius': -4 }) + const repaired = repairGenome(broken, f.specs) + expect(validateGenome(repaired, f.specs, context(valid))).toEqual({ ok: true, errors: [] }) + expect(repaired.physical.maxSpeed).toBe(40) + expect(repaired.sensory.sensorRadius).toBe(30) + }) + + it('resets non-finite genes to their baseline and zeroes non-finite weights', () => { + const weights = valid.brain.weights.slice() + weights[0] = Number.NaN + const broken = withGenes(valid, { 'physical.maxSpeed': Number.NaN }, weights) + const repaired = repairGenome(broken, f.specs) + expect(repaired.physical.maxSpeed).toBe(f.config.organisms.maxSpeed) + expect(repaired.brain.weights[0]).toBe(0) + }) + + it('restores the life-stage ordering', () => { + const broken = withGenes(valid, { 'physical.senescenceAge': 100, 'physical.maturityAge': 150 }) + const repaired = repairGenome(broken, f.specs) + expect(repaired.physical.senescenceAge).toBeGreaterThan(repaired.physical.maturityAge) + }) + + it('keeps the genome identity', () => { + const child = genomeFromParts({ + id: 7, + parentIds: [1, 2], + vector: genomeToVector(valid), + architecture: valid.brain.architecture, + weights: valid.brain.weights + }) + const repaired = repairGenome(child, f.specs) + expect(repaired.id).toBe(7) + expect(repaired.parentIds).toEqual([1, 2]) + }) +}) + +describe('repairGeneValues (synthetic table)', () => { + const specs = synthetic([ + { path: 'g.float', kind: 'float', min: 0, max: 10, baseline: 5 }, + { path: 'g.int', kind: 'int', min: 0, max: 5, baseline: 2 }, + { path: 'g.bool', kind: 'bool', min: 0, max: 1, baseline: 0 }, + { path: 'g.hue', kind: 'circular', min: 0, max: 1, baseline: 0.5 } + ]) + + it('rounds integers and clamps them into range', () => { + expect(repairGeneValues([5, 2.6, 0, 0.5], specs)[1]).toBe(3) + expect(repairGeneValues([5, 99, 0, 0.5], specs)[1]).toBe(5) + expect(repairGeneValues([5, -3, 0, 0.5], specs)[1]).toBe(0) + }) + + it('snaps booleans to 0 or 1', () => { + expect(repairGeneValues([5, 2, 0.4, 0.5], specs)[2]).toBe(0) + expect(repairGeneValues([5, 2, 0.6, 0.5], specs)[2]).toBe(1) + }) + + it('wraps circular genes instead of clamping them', () => { + expect(repairGeneValues([5, 2, 0, 1.25], specs)[3]).toBeCloseTo(0.25, 12) + expect(repairGeneValues([5, 2, 0, -0.25], specs)[3]).toBeCloseTo(0.75, 12) + }) + + it('resets non-finite values to the baseline', () => { + expect(repairGeneValues([Number.NaN, Infinity, Number.NaN, -Infinity], specs)).toEqual([ + 5, 2, 0, 0.5 + ]) + }) + + it('produces vectors that validate', () => { + const repaired = repairGeneValues([99, 2.6, 0.9, 7.3], specs) + expect(validateGeneValues(repaired, specs)).toEqual([]) + }) +}) diff --git a/packages/simulation/tests/systems.test.ts b/packages/simulation/tests/systems.test.ts index 649b15c..b8dc2c6 100644 --- a/packages/simulation/tests/systems.test.ts +++ b/packages/simulation/tests/systems.test.ts @@ -593,7 +593,7 @@ describe('ReproductionSystem', () => { expect(organisms.get(1)?.reproductionCooldownRemaining).toBe(0) }) - it('pairs two eligible organisms: both pay energyCost, both get the cooldown, a child is born', () => { + 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 })) @@ -612,8 +612,12 @@ describe('ReproductionSystem', () => { events ).update(1) - expect(organisms.get(1)?.energy).toBe(100 - config.reproduction.energyCost) - expect(organisms.get(2)?.energy).toBe(100 - config.reproduction.energyCost) + const cost = + config.reproduction.offspringInvestment * (1 + config.reproduction.overheadFraction) + expect(cost).toBe(20) + expect(organisms.get(1)?.energy).toBe(100 - cost) + expect(organisms.get(2)?.energy).toBe(100 - cost) + expect(organisms.get(3)?.energy).toBe(2 * config.reproduction.offspringInvestment) expect(organisms.get(1)?.reproductionCooldownRemaining).toBe(config.reproduction.cooldown) expect(organisms.get(2)?.reproductionCooldownRemaining).toBe(config.reproduction.cooldown) diff --git a/packages/simulation/tests/world.test.ts b/packages/simulation/tests/world.test.ts index dae875e..7c2043d 100644 --- a/packages/simulation/tests/world.test.ts +++ b/packages/simulation/tests/world.test.ts @@ -261,8 +261,8 @@ describe('scenario C: reproduction (roadmap testing scenario C)', () => { resources: { initialCount: 0, spawnRate: 0 }, reproduction: { minEnergy: 60, - energyCost: 10, - offspringEnergy: 20, + offspringInvestment: 10, + overheadFraction: 0, cooldown: 5, searchRadius: 1000 }